mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
314 lines
13 KiB
Rust
314 lines
13 KiB
Rust
//! 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<String>,
|
|
/// `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<KeySalt>,
|
|
}
|
|
|
|
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<Entry> {
|
|
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<String>,
|
|
) -> 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<String>,
|
|
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
|
) -> 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<String>,
|
|
) -> 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<KeySalt>,
|
|
) -> 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<StoredSession> {
|
|
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<KeySalt>,
|
|
) {
|
|
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");
|
|
}
|
|
}
|
|
});
|
|
}
|