mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
Logout only deleted the keyring entry, so the session's tokens stayed valid on Proton's side. It now ends the session first (DELETE auth/v4, bounded to 10 s) and signs out locally either way. `pdfs logout` says when Proton could not be reached (B233).
1325 lines
51 KiB
Rust
1325 lines
51 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, the account's key salts and — unless
|
|
//! the user chose otherwise — the per-key *passphrases* derived from the
|
|
//! mailbox password. The password itself is never stored: the passphrases are
|
|
//! what it exists to produce, and they are enough 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.
|
|
//!
|
|
//! # Two-password accounts
|
|
//!
|
|
//! An account with a separate mailbox password exists precisely so that no one
|
|
//! login holds the means to read the data. Storing that password in the keyring
|
|
//! would defeat it, so such an account is **locked by default**: its
|
|
//! passphrases are kept only in the kernel keyring ([`crate::kernelkey`]), for
|
|
//! the length of the user's session, and `pdfs unlock` (or the app) supplies
|
|
//! them again after each login. Storing them in the Secret Service as well is
|
|
//! an explicit opt-in ([`Mailbox::remember`]). Single-password accounts have no
|
|
//! such separation to protect and are stored, as they always were.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
use keyring_core::Entry;
|
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
|
use proton_sdk::account::KeyPassphrases;
|
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
|
use proton_sdk::cache::EncryptedCacheRepository;
|
|
use proton_sdk::config::ProtonClientConfiguration;
|
|
use proton_sdk::error::ProtonError;
|
|
use proton_sdk::http::Tokens;
|
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
|
use proton_sdk::telemetry::TracingTelemetry;
|
|
use serde::{Deserialize, Serialize};
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
|
use crate::error::{Error, Result};
|
|
use crate::kernelkey;
|
|
|
|
/// How often a wrong mailbox password is asked for again before the login gives up.
|
|
const MAILBOX_ATTEMPTS: u32 = 3;
|
|
|
|
/// Domain-separation label for the key that encrypts the persistent entity cache.
|
|
const ENTITY_CACHE_PURPOSE: &[u8] = b"pdfs entity cache v1";
|
|
|
|
/// 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,
|
|
/// The mailbox password of a blob written by 3.0.x and earlier.
|
|
///
|
|
/// Read only, to migrate: [`resume_client`] derives the passphrases from it
|
|
/// once and writes the blob back without it. Never serialized again.
|
|
#[serde(default, skip_serializing)]
|
|
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>,
|
|
/// Per-key passphrases (key id → base64), when the user keeps them in the
|
|
/// keyring: always for a single-password account, by opt-in for a
|
|
/// two-password one. `None` means they are not stored here.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub key_passphrases: Option<BTreeMap<String, String>>,
|
|
}
|
|
|
|
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.resolved_app_version().to_string(),
|
|
config.resolved_user_agent().to_string(),
|
|
)
|
|
}
|
|
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
|
|
};
|
|
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
|
|
}
|
|
|
|
fn keyring_entry() -> Result<Entry> {
|
|
// Installed lazily rather than once at startup so a session bus that was not
|
|
// up yet on the first attempt is retried on the next one.
|
|
if keyring_core::get_default_store().is_none() {
|
|
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
|
}
|
|
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
|
}
|
|
|
|
/// What the user answered when asked for the mailbox password.
|
|
pub struct Mailbox {
|
|
pub password: Zeroizing<String>,
|
|
/// Keep the derived passphrases in the keyring, so the daemon unlocks by
|
|
/// itself after every login. Off by default: it trades the protection a
|
|
/// separate mailbox password gives for convenience.
|
|
pub remember: bool,
|
|
}
|
|
|
|
/// Where the passphrases that unlock this account's keys currently live.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum KeyState {
|
|
/// In the keyring, available after every login.
|
|
Stored,
|
|
/// In kernel memory for this login session only.
|
|
Session,
|
|
/// Nowhere: the mailbox password has to be entered (`pdfs unlock`).
|
|
Locked,
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// `get_mailbox` is only invoked for a two-password account. It receives the
|
|
/// number of earlier wrong answers (so a prompt can say "try again") and is
|
|
/// asked at most [`MAILBOX_ATTEMPTS`] times; an `Err` from it aborts the login.
|
|
///
|
|
/// 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>,
|
|
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
|
) -> Result<()> {
|
|
login_verified(username, password, None, get_totp, get_mailbox).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_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
|
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
|
) -> Result<()> {
|
|
match login_verified(username, password, None, &get_totp, &get_mailbox).await {
|
|
Err(Error::HumanVerificationRequired(hv)) => {
|
|
let credential = get_hv(*hv)?;
|
|
login_verified(
|
|
username,
|
|
password,
|
|
Some(&credential),
|
|
&get_totp,
|
|
&get_mailbox,
|
|
)
|
|
.await
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// Proton's verification page for the user's own browser, for a front-end that
|
|
/// cannot host it in a webview.
|
|
///
|
|
/// Unlike [`HumanVerification::verification_url`] this asks for the full page,
|
|
/// not the embedded one: a browser tab has no host to post the token to, so the
|
|
/// page has to tell the user they are done instead. Proton Mail Bridge opens the
|
|
/// same URL.
|
|
pub fn browser_verification_url(hv: &HumanVerification) -> String {
|
|
let token: String = hv
|
|
.token
|
|
.bytes()
|
|
.map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
(b as char).to_string()
|
|
}
|
|
_ => format!("%{b:02X}"),
|
|
})
|
|
.collect();
|
|
format!(
|
|
"https://verify.proton.me/?methods={}&token={token}",
|
|
hv.methods.join(","),
|
|
)
|
|
}
|
|
|
|
/// The credential to retry with once the user has solved the challenge in
|
|
/// their browser.
|
|
///
|
|
/// The browser keeps the page's own token, so the retry carries the challenge
|
|
/// token instead: solving the page marks that token as verified on Proton's
|
|
/// side. The type is every offered method, as Proton Mail Bridge sends it, since
|
|
/// the user may have picked any of them on the page.
|
|
pub fn browser_verified(hv: &HumanVerification) -> HumanVerificationCredential {
|
|
HumanVerificationCredential {
|
|
token: hv.token.clone(),
|
|
method: hv.methods.join(","),
|
|
}
|
|
}
|
|
|
|
/// [`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>,
|
|
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
|
) -> Result<()> {
|
|
let password = Zeroizing::new(password.to_owned());
|
|
let session = ProtonApiSession::begin_verified(
|
|
client_config(),
|
|
username,
|
|
password.as_bytes(),
|
|
verification,
|
|
)
|
|
.await
|
|
.map_err(classify_verification_gate);
|
|
let mut session = session?;
|
|
|
|
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 key_salts = ProtonDriveClient::new(&session, Vec::new())
|
|
.account()
|
|
.key_salts()
|
|
.await?;
|
|
|
|
// A single-password account's mailbox password is the login password. A
|
|
// two-password one has a second secret the user must type, and it is checked
|
|
// against the account now — a typo found at login is a retry, found later it
|
|
// is a daemon that never mounts.
|
|
let (passphrases, remember) = match session.password_mode() {
|
|
PasswordMode::Single => (
|
|
derive_verified(&session, password.as_bytes(), key_salts.clone()).await?,
|
|
true,
|
|
),
|
|
PasswordMode::Dual => {
|
|
let mut attempt = 0;
|
|
loop {
|
|
let mailbox = get_mailbox(attempt)?;
|
|
match derive_verified(&session, mailbox.password.as_bytes(), key_salts.clone())
|
|
.await
|
|
{
|
|
Ok(passphrases) => break (passphrases, mailbox.remember),
|
|
Err(Error::WrongMailboxPassword) if attempt + 1 < MAILBOX_ATTEMPTS => {
|
|
attempt += 1;
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let tokens = session.current_tokens().await;
|
|
register_refresh_handler(&session, tokens.refresh_token.clone());
|
|
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: String::new(),
|
|
key_salts,
|
|
key_passphrases: None,
|
|
};
|
|
keep_passphrases(stored, &passphrases, remember)
|
|
}
|
|
|
|
/// Derive the key passphrases from `mailbox` and prove they unlock the account.
|
|
///
|
|
/// [`Error::WrongMailboxPassword`] means the account answered and the password
|
|
/// is wrong; any other error says nothing about the password (no network, an
|
|
/// API fault) and must not be reported as a typo.
|
|
async fn derive_verified(
|
|
session: &ProtonApiSession,
|
|
mailbox: &[u8],
|
|
key_salts: Vec<KeySalt>,
|
|
) -> Result<KeyPassphrases> {
|
|
let client = ProtonDriveClient::with_key_salts(session, mailbox.to_vec(), key_salts);
|
|
client.account().unlock().await.map_err(|e| match e {
|
|
ProtonError::KeysLocked => Error::WrongMailboxPassword,
|
|
other => other.into(),
|
|
})?;
|
|
Ok(client.account().key_passphrases().await?)
|
|
}
|
|
|
|
/// Write `stored` with the passphrases in the place the user chose: the
|
|
/// keyring (`remember`) or kernel memory only. Whichever is not chosen is
|
|
/// cleared, so a revoked choice never leaves a copy behind.
|
|
///
|
|
/// The kernel entry is written first. If it cannot be (no keyring support), the
|
|
/// secret is *not* quietly stored somewhere weaker: the blob is written locked
|
|
/// and the error says so.
|
|
fn keep_passphrases(
|
|
mut stored: StoredSession,
|
|
passphrases: &KeyPassphrases,
|
|
remember: bool,
|
|
) -> Result<()> {
|
|
let outcome = if remember {
|
|
stored.key_passphrases = Some(encode(passphrases));
|
|
// Best-effort: a system without a kernel keyring has nothing to clear.
|
|
let _ = kernelkey::PASSPHRASES.clear();
|
|
Ok(())
|
|
} else {
|
|
stored.key_passphrases = None;
|
|
kernelkey::PASSPHRASES.store(&encode_secret(passphrases)).map_err(|e| {
|
|
Error::Other(format!(
|
|
"the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}"
|
|
))
|
|
})
|
|
};
|
|
write_stored(&stored)?;
|
|
outcome
|
|
}
|
|
|
|
fn encode(passphrases: &KeyPassphrases) -> BTreeMap<String, String> {
|
|
passphrases
|
|
.iter()
|
|
.map(|(id, secret)| (id.to_owned(), BASE64.encode(secret)))
|
|
.collect()
|
|
}
|
|
|
|
fn decode(encoded: &BTreeMap<String, String>) -> Option<KeyPassphrases> {
|
|
let mut passphrases = KeyPassphrases::new();
|
|
for (id, secret) in encoded {
|
|
passphrases.insert(id.clone(), BASE64.decode(secret).ok()?);
|
|
}
|
|
(!passphrases.is_empty()).then_some(passphrases)
|
|
}
|
|
|
|
/// The kernel-keyring payload: the same map as the blob's, as JSON.
|
|
fn encode_secret(passphrases: &KeyPassphrases) -> Zeroizing<Vec<u8>> {
|
|
Zeroizing::new(serde_json::to_vec(&encode(passphrases)).unwrap_or_default())
|
|
}
|
|
|
|
fn decode_secret(secret: &[u8]) -> Option<KeyPassphrases> {
|
|
decode(&serde_json::from_slice(secret).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: ProtonError) -> Error {
|
|
let 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(),
|
|
}
|
|
}
|
|
|
|
fn write_stored(stored: &StoredSession) -> Result<()> {
|
|
let json = Zeroizing::new(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_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Where the passphrases for the stored session live right now.
|
|
///
|
|
/// Reads the kernel keyring and the stored blob only; never the network.
|
|
pub fn key_state() -> Result<KeyState> {
|
|
Ok(key_state_of(&load()?))
|
|
}
|
|
|
|
fn key_state_of(stored: &StoredSession) -> KeyState {
|
|
match find_passphrases(stored) {
|
|
Ok(Some((_, origin))) => origin,
|
|
// A 3.0.x blob still holds its password; `resume_client` migrates it.
|
|
_ if !stored.mailbox_password.is_empty() => KeyState::Stored,
|
|
_ => KeyState::Locked,
|
|
}
|
|
}
|
|
|
|
/// Whether the stored session can be resumed without asking the user:
|
|
/// `Ok` when it can, [`Error::NotLoggedIn`] or [`Error::Locked`] when not.
|
|
/// What the daemon waits on before it mounts.
|
|
pub fn ready() -> Result<()> {
|
|
match key_state_of(&load()?) {
|
|
KeyState::Locked => Err(Error::Locked),
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// The passphrases the stored session can use without being asked, and which
|
|
/// of the two places they came from. Prefers the keyring blob: it is what the
|
|
/// user opted into. A corrupt entry is treated as absent, so it reads as
|
|
/// locked instead of wedging the daemon.
|
|
fn find_passphrases(stored: &StoredSession) -> Result<Option<(KeyPassphrases, KeyState)>> {
|
|
if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) {
|
|
return Ok(Some((found, KeyState::Stored)));
|
|
}
|
|
match kernelkey::PASSPHRASES.load() {
|
|
Ok(Some(secret)) => Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))),
|
|
Ok(None) => Ok(None),
|
|
// No keyring on this system: nothing was ever kept there.
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "kernel keyring not readable; treating the account as locked");
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Supply the mailbox password of a locked account.
|
|
///
|
|
/// Verifies it against the account, then keeps the derived passphrases in
|
|
/// kernel memory for this login session — or, with `remember`, in the keyring.
|
|
/// A daemon waiting on [`Error::Locked`] picks them up on its next attempt.
|
|
///
|
|
/// Fails with [`Error::WrongMailboxPassword`] for a wrong password, which
|
|
/// changes nothing, and [`Error::ReloginRequired`] for a session that predates
|
|
/// stored key salts.
|
|
pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> {
|
|
let stored = load()?;
|
|
if stored.key_salts.is_empty() {
|
|
return Err(Error::ReloginRequired);
|
|
}
|
|
let mailbox = Zeroizing::new(mailbox.to_owned());
|
|
let session = resume_stored(&stored)?;
|
|
let passphrases =
|
|
derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?;
|
|
// Read again: verifying may have rotated the tokens, and the copy read
|
|
// above would put the spent refresh token back.
|
|
keep_passphrases(load()?, &passphrases, remember)
|
|
}
|
|
|
|
/// Drop the passphrases held in kernel memory, so the account is locked again
|
|
/// until [`unlock`]. With `forget`, also those in the keyring blob.
|
|
pub fn lock(forget: bool) -> Result<()> {
|
|
kernelkey::PASSPHRASES.clear()?;
|
|
if forget {
|
|
let mut stored = load()?;
|
|
if stored.key_passphrases.take().is_some() {
|
|
write_stored(&stored)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// How long [`logout`] waits for Proton to end the session before it signs
|
|
/// out locally without.
|
|
const END_SESSION_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// End the stored session on Proton's side (`DELETE auth/v4`). Returns false
|
|
/// when there is none or Proton could not be reached in time.
|
|
///
|
|
/// Runs on a thread of its own, so it works the same whether the caller is
|
|
/// inside a tokio runtime or not.
|
|
fn end_server_session() -> bool {
|
|
let Ok(stored) = load() else {
|
|
return false;
|
|
};
|
|
let ended = std::thread::scope(|scope| {
|
|
scope
|
|
.spawn(|| -> std::result::Result<(), String> {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
rt.block_on(async {
|
|
let session = resume_stored(&stored).map_err(|e| e.to_string())?;
|
|
match tokio::time::timeout(END_SESSION_TIMEOUT, session.end()).await {
|
|
Ok(result) => result.map_err(|e| e.to_string()),
|
|
Err(_) => Err(format!(
|
|
"no answer within {}s",
|
|
END_SESSION_TIMEOUT.as_secs()
|
|
)),
|
|
}
|
|
})
|
|
})
|
|
.join()
|
|
.unwrap_or_else(|_| Err("the sign-out thread panicked".into()))
|
|
});
|
|
match ended {
|
|
Ok(()) => true,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "ending the session on Proton failed; signing out locally");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// End the session on Proton, then forget it here. Returns whether Proton
|
|
/// ended it: offline, the session stays valid on Proton's side until it
|
|
/// expires or is signed out from the account's security settings.
|
|
///
|
|
/// Before this, signing out only deleted the keyring entry. The tokens stayed
|
|
/// valid on Proton, so a copy of them taken from this machine still worked.
|
|
///
|
|
/// The local half is best effort; an absent entry is not an error.
|
|
pub fn logout() -> Result<bool> {
|
|
let ended = end_server_session();
|
|
// The persisted entity cache describes the account being logged out of. It
|
|
// is encrypted under a key derived from that account's passphrases, but
|
|
// leaving it behind would also mean the next account inherits a store it can
|
|
// only ever read as misses. Best-effort: failing to remove a cache must not
|
|
// block the logout.
|
|
if let Ok(dirs) = AppDirs::new() {
|
|
let path = dirs.state_dir().join("sdk_cache.db");
|
|
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
|
&& let Err(e) = cache.clear_now()
|
|
{
|
|
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
|
|
}
|
|
for suffix in ["", "-wal", "-shm"] {
|
|
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
|
}
|
|
}
|
|
if let Err(e) = kernelkey::PASSPHRASES.clear() {
|
|
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
|
}
|
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
|
match keyring_entry()?.delete_credential() {
|
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Resume a persisted session and build an authenticated Drive client.
|
|
///
|
|
/// Returns `Error::NotLoggedIn` when no session has been saved and
|
|
/// [`Error::Locked`] when the account is waiting for its mailbox password. Tokens
|
|
/// the session rotates are written back to the keyring as they happen (see
|
|
/// [`register_refresh_handler`]); Proton refresh tokens are single-use, so a
|
|
/// refresh that is not written back would leave the keyring holding a stale
|
|
/// refresh token, and the next resume would fail with `InvalidRefreshToken`.
|
|
///
|
|
/// Makes no network call for an account whose passphrases are already known, so
|
|
/// a daemon starting offline can still mount from its cache.
|
|
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
|
let stored = load()?;
|
|
let session = resume_stored(&stored)?;
|
|
|
|
let passphrases = match find_passphrases(&stored)? {
|
|
Some((passphrases, _)) => passphrases,
|
|
None if !stored.mailbox_password.is_empty() => migrate_legacy(&session, &stored).await?,
|
|
None => return Err(Error::Locked),
|
|
};
|
|
|
|
let client = tune(
|
|
ProtonDriveClient::with_key_passphrases(&session, passphrases.clone()),
|
|
&passphrases,
|
|
);
|
|
Ok((client, session))
|
|
}
|
|
|
|
/// Resume `stored`, with the rotation the keyring refused last time put in
|
|
/// first, if there is one: Proton has spent the stored refresh token then,
|
|
/// and the kernel keyring holds the one that replaced it (B61).
|
|
fn resume_stored(stored: &StoredSession) -> Result<ProtonApiSession> {
|
|
let unsaved = recover_unsaved(stored);
|
|
let mut resumed = stored.clone();
|
|
if let Some(tokens) = &unsaved {
|
|
tracing::info!("resuming with session tokens the keyring refused last time");
|
|
resumed.access_token = tokens.access_token.clone();
|
|
resumed.refresh_token = tokens.refresh_token.clone();
|
|
}
|
|
let session = ProtonApiSession::resume(client_config(), resumed.to_params())?;
|
|
let sink = register_refresh_handler(&session, stored.refresh_token.clone());
|
|
if let Some(tokens) = unsaved {
|
|
let _ = sink.rotated(tokens);
|
|
}
|
|
Ok(session)
|
|
}
|
|
|
|
/// The rotation the kernel keyring holds for `stored`, if it belongs there.
|
|
/// One that does not is from an older login, or was written since, and is
|
|
/// dropped.
|
|
fn recover_unsaved(stored: &StoredSession) -> Option<Tokens> {
|
|
let kept = match kernelkey::UNSAVED_TOKENS.load() {
|
|
Ok(kept) => kept?,
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "kernel keyring unavailable; no unsaved tokens to recover");
|
|
return None;
|
|
}
|
|
};
|
|
match serde_json::from_slice::<UnsavedRotation>(&kept) {
|
|
Ok(unsaved) if unsaved.belongs_in(stored) => Some(Tokens {
|
|
access_token: unsaved.access_token,
|
|
refresh_token: unsaved.refresh_token,
|
|
}),
|
|
_ => {
|
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Turn a blob written by 3.0.x — which holds the mailbox password — into one
|
|
/// that holds passphrases, so the password stops being stored.
|
|
///
|
|
/// The old login saved whatever was typed as the mailbox password, which for a
|
|
/// two-password account is the *login* password and unlocks nothing. That is
|
|
/// checked here: a password the account rejects leaves the account locked (and
|
|
/// the stored copy gone) rather than failing every start forever.
|
|
async fn migrate_legacy(
|
|
session: &ProtonApiSession,
|
|
stored: &StoredSession,
|
|
) -> Result<KeyPassphrases> {
|
|
let legacy = Zeroizing::new(stored.mailbox_password.clone());
|
|
|
|
// Blobs from before key salts were persisted have none: fetch them once
|
|
// more, 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() {
|
|
ProtonDriveClient::new(session, Vec::new())
|
|
.account()
|
|
.key_salts()
|
|
.await
|
|
.map_err(|e| match &e {
|
|
ProtonError::Api(api) if api.is_insufficient_scope() => Error::ReloginRequired,
|
|
_ => e.into(),
|
|
})?
|
|
} else {
|
|
stored.key_salts.clone()
|
|
};
|
|
|
|
let verified = derive_verified(session, legacy.as_bytes(), key_salts.clone()).await;
|
|
// Read again: the calls above may have rotated the tokens, and `stored`
|
|
// would put the spent refresh token back.
|
|
let mut migrated = load()?;
|
|
migrated.mailbox_password = String::new();
|
|
migrated.key_salts = key_salts;
|
|
|
|
match verified {
|
|
Ok(passphrases) => {
|
|
migrated.key_passphrases = Some(encode(&passphrases));
|
|
write_stored(&migrated)?;
|
|
tracing::info!(
|
|
"migrated the stored session from a mailbox password to key passphrases"
|
|
);
|
|
Ok(passphrases)
|
|
}
|
|
Err(Error::WrongMailboxPassword) => {
|
|
write_stored(&migrated)?;
|
|
tracing::warn!(
|
|
"the stored password does not unlock the account's keys; unlock required"
|
|
);
|
|
Err(Error::Locked)
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// The client settings every front-end wants, applied wherever a
|
|
/// [`ProtonDriveClient`] is built for real work.
|
|
///
|
|
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
|
|
/// storage, per-request timings) land in the daemon's log next to the
|
|
/// daemon's, instead of being dropped by the default no-op sink.
|
|
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
|
|
/// single atomic request instead of the multi-step draft/block/commit dance.
|
|
/// The SDK makes this opt-in because it has no remote feature-flag provider;
|
|
/// the drain queue writes plenty of small files, and the shorter path is one
|
|
/// failure point per write instead of three.
|
|
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
|
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
|
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by a secret derived
|
|
/// from the key passphrases, so the decrypted node names it holds are not
|
|
/// readable from the file alone — and a password change simply reads as a cold
|
|
/// cache, since the SDK treats an undecryptable entry as a miss and clears the
|
|
/// store.
|
|
///
|
|
/// Opening it is best-effort: a store this process cannot open costs cache
|
|
/// hits, not the session, so the client falls back to the SDK's in-memory
|
|
/// default.
|
|
fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveClient {
|
|
let client = client
|
|
.with_telemetry(TracingTelemetry::shared())
|
|
.with_small_file_upload(true);
|
|
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
|
Ok(cache) => {
|
|
let key = passphrases.derive_secret(ENTITY_CACHE_PURPOSE);
|
|
client.with_entity_repository(EncryptedCacheRepository::shared(cache, key.to_vec()))
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
|
client
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write any rotation of the session's tokens that has not reached the keyring
|
|
/// yet. A best-effort last chance before the process exits.
|
|
///
|
|
/// Rotations are written as they happen (see [`register_refresh_handler`]), so
|
|
/// this usually has nothing to do. It never writes the session's current
|
|
/// tokens for their own sake: the blob may by now belong to a newer login, or
|
|
/// hold a newer rotation, and writing them would put a spent refresh token
|
|
/// over a live one.
|
|
pub fn persist(session: &ProtonApiSession) -> Result<()> {
|
|
let sink = TOKEN_SINKS
|
|
.lock()
|
|
.get(session.session_id().as_str())
|
|
.cloned();
|
|
match sink {
|
|
Some(sink) => sink.flush(),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Whether the keyring holds a session newer than any this process runs on:
|
|
/// another login, or one of this process's sessions rotated by another
|
|
/// process (`pdfs unlock` resumes the same login and may refresh it).
|
|
///
|
|
/// The refresh token this process holds is spent then, and Proton refuses it
|
|
/// as if the session were gone. A daemon asks this before it reports the
|
|
/// session expired, and resumes the stored one instead.
|
|
pub fn stored_session_is_newer() -> bool {
|
|
// Nothing to compare with, and no keyring to wake up for it.
|
|
if TOKEN_SINKS.lock().is_empty() {
|
|
return false;
|
|
}
|
|
let Ok(stored) = load() else {
|
|
return false;
|
|
};
|
|
let sinks = TOKEN_SINKS.lock();
|
|
match sinks.get(&stored.session_id) {
|
|
Some(sink) => sink.state.lock().stored_refresh != stored.refresh_token,
|
|
None => !sinks.is_empty(),
|
|
}
|
|
}
|
|
|
|
/// Whether a session in this process holds refreshed tokens the keyring has
|
|
/// not taken yet. They are retried and kept in the kernel keyring meanwhile,
|
|
/// which a reboot clears, so the user is asked to unlock the keyring (B61).
|
|
pub fn tokens_unsaved() -> bool {
|
|
TOKEN_SINKS
|
|
.lock()
|
|
.values()
|
|
.any(|sink| sink.state.lock().unsaved.is_some())
|
|
}
|
|
|
|
/// Where each live session in this process writes its rotated tokens, by
|
|
/// session id, for [`persist`] to find.
|
|
static TOKEN_SINKS: parking_lot::Mutex<BTreeMap<String, Arc<TokenSink>>> =
|
|
parking_lot::Mutex::new(BTreeMap::new());
|
|
|
|
/// One session's rotated tokens on their way to the keyring.
|
|
///
|
|
/// Proton refresh tokens are single-use, so a rotation has to reach the
|
|
/// keyring, and it must reach only the blob it belongs to. That is the blob of
|
|
/// this session id that still holds the refresh token this session last knew
|
|
/// it to hold. Anything else means the blob moved on without this session: a
|
|
/// new login, or another session on the same login (`pdfs unlock`) that
|
|
/// rotated first. Writing into it would replace a live refresh token with a
|
|
/// spent one, and the next start fails with `InvalidRefreshToken` (issue #35).
|
|
struct TokenSink {
|
|
session_id: String,
|
|
state: parking_lot::Mutex<SinkState>,
|
|
/// Whether a thread is retrying a write the keyring refused.
|
|
retrying: AtomicBool,
|
|
}
|
|
|
|
/// How long to wait before each new try at a write the keyring refused; the
|
|
/// last delay repeats until it succeeds. A locked or restarting keyring
|
|
/// usually comes back within seconds, and one that does not must not be asked
|
|
/// in a tight loop.
|
|
const KEYRING_RETRY: [Duration; 4] = [
|
|
Duration::from_secs(1),
|
|
Duration::from_secs(5),
|
|
Duration::from_secs(30),
|
|
Duration::from_secs(300),
|
|
];
|
|
|
|
struct SinkState {
|
|
/// The refresh token the blob holds for this session, as far as this
|
|
/// session knows: the one it started from, or the last one it wrote.
|
|
stored_refresh: String,
|
|
/// The latest rotation, until the keyring has taken it.
|
|
unsaved: Option<Tokens>,
|
|
/// Whether `unsaved` is also in [`kernelkey::UNSAVED_TOKENS`].
|
|
kept: bool,
|
|
}
|
|
|
|
/// A rotation the keyring refused, as kept in the kernel keyring until it is
|
|
/// written: the tokens, and the blob they go into.
|
|
#[derive(Serialize, Deserialize)]
|
|
struct UnsavedRotation {
|
|
session_id: String,
|
|
/// The refresh token this rotation spent, which the blob still holds.
|
|
spent: String,
|
|
access_token: String,
|
|
refresh_token: String,
|
|
}
|
|
|
|
impl UnsavedRotation {
|
|
/// Whether `stored` is the blob this rotation was refused for, still
|
|
/// holding the refresh token it spent.
|
|
fn belongs_in(&self, stored: &StoredSession) -> bool {
|
|
self.session_id == stored.session_id && self.spent == stored.refresh_token
|
|
}
|
|
}
|
|
|
|
/// What became of a rotation offered to a stored blob.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Rotation {
|
|
/// The blob is this session's and took the tokens.
|
|
Taken,
|
|
/// The blob moved on without this session; it is left alone.
|
|
MovedOn,
|
|
}
|
|
|
|
/// Put `tokens` into `stored` if it is still this session's blob: the same
|
|
/// session id, holding the refresh token this session last left in it, or
|
|
/// already holding this very rotation (another process wrote it first).
|
|
fn take_rotation(
|
|
stored: &mut StoredSession,
|
|
session_id: &str,
|
|
stored_refresh: &str,
|
|
tokens: &Tokens,
|
|
) -> Rotation {
|
|
if stored.session_id != session_id
|
|
|| (stored.refresh_token != stored_refresh && stored.refresh_token != tokens.refresh_token)
|
|
{
|
|
return Rotation::MovedOn;
|
|
}
|
|
stored.access_token = tokens.access_token.clone();
|
|
stored.refresh_token = tokens.refresh_token.clone();
|
|
Rotation::Taken
|
|
}
|
|
|
|
impl TokenSink {
|
|
fn new(session_id: &str, stored_refresh: String) -> Self {
|
|
Self {
|
|
session_id: session_id.to_owned(),
|
|
state: parking_lot::Mutex::new(SinkState {
|
|
stored_refresh,
|
|
unsaved: None,
|
|
kept: false,
|
|
}),
|
|
retrying: AtomicBool::new(false),
|
|
}
|
|
}
|
|
|
|
/// Take a new rotation and try to write it. If the keyring refuses, keep
|
|
/// trying in the background: until then the only live refresh token is in
|
|
/// memory, and a stop that cannot write it either loses the session
|
|
/// (`docs/BUGS.md` B61).
|
|
fn rotated(self: &Arc<Self>, tokens: Tokens) -> Result<()> {
|
|
self.state.lock().unsaved = Some(tokens);
|
|
let written = self.flush();
|
|
if let Err(e) = &written {
|
|
tracing::error!(
|
|
error = %e,
|
|
"the keyring refused the refreshed session tokens; retrying"
|
|
);
|
|
self.keep_in_kernel();
|
|
self.retry_in_background();
|
|
}
|
|
written
|
|
}
|
|
|
|
/// Keep the unsaved rotation in the kernel keyring too, so a process that
|
|
/// dies before the retry lands does not take the only live refresh token
|
|
/// with it. The next resume puts it back (see [`resume_stored`]).
|
|
fn keep_in_kernel(&self) {
|
|
let mut state = self.state.lock();
|
|
let Some(tokens) = &state.unsaved else {
|
|
return;
|
|
};
|
|
let unsaved = UnsavedRotation {
|
|
session_id: self.session_id.clone(),
|
|
spent: state.stored_refresh.clone(),
|
|
access_token: tokens.access_token.clone(),
|
|
refresh_token: tokens.refresh_token.clone(),
|
|
};
|
|
let Ok(json) = serde_json::to_vec(&unsaved).map(Zeroizing::new) else {
|
|
return;
|
|
};
|
|
match kernelkey::UNSAVED_TOKENS.store(&json) {
|
|
Ok(()) => state.kept = true,
|
|
Err(e) => tracing::error!(
|
|
error = %e,
|
|
"the kernel keyring refused them too; if this process stops first, \
|
|
the next start needs `pdfs login`"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Retry the unsaved rotation on [`KEYRING_RETRY`] until it is written or
|
|
/// has nowhere left to go. One thread per sink; a newer rotation simply
|
|
/// replaces what it is retrying.
|
|
fn retry_in_background(self: &Arc<Self>) {
|
|
if self.retrying.swap(true, Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
let sink = Arc::clone(self);
|
|
let spawned = std::thread::Builder::new()
|
|
.name("pdfs-keyring-retry".into())
|
|
.spawn(move || {
|
|
loop {
|
|
let delays = KEYRING_RETRY
|
|
.into_iter()
|
|
.chain(std::iter::repeat(KEYRING_RETRY[KEYRING_RETRY.len() - 1]));
|
|
for delay in delays {
|
|
std::thread::sleep(delay);
|
|
match sink.flush() {
|
|
Ok(()) => break,
|
|
Err(e) => tracing::debug!(error = %e, ?delay, "keyring still refuses"),
|
|
}
|
|
}
|
|
sink.retrying.store(false, Ordering::SeqCst);
|
|
// A rotation refused after the last try succeeded found
|
|
// this thread still running, and started none of its own.
|
|
if sink.state.lock().unsaved.is_none()
|
|
|| sink.retrying.swap(true, Ordering::SeqCst)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
if let Err(e) = spawned {
|
|
self.retrying.store(false, Ordering::SeqCst);
|
|
tracing::error!(error = %e, "could not start retrying the keyring write");
|
|
}
|
|
}
|
|
|
|
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
|
/// is gone with a logout, drops it: there is nothing it may still go into.
|
|
fn flush(&self) -> Result<()> {
|
|
let mut state = self.state.lock();
|
|
let Some(tokens) = state.unsaved.clone() else {
|
|
return Ok(());
|
|
};
|
|
let mut stored = match load() {
|
|
Ok(stored) => stored,
|
|
Err(Error::NotLoggedIn) => {
|
|
state.settle();
|
|
return Ok(());
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
match take_rotation(
|
|
&mut stored,
|
|
&self.session_id,
|
|
&state.stored_refresh,
|
|
&tokens,
|
|
) {
|
|
Rotation::Taken => {
|
|
write_stored(&stored)?;
|
|
state.stored_refresh = tokens.refresh_token;
|
|
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
|
|
}
|
|
Rotation::MovedOn => {
|
|
tracing::info!(
|
|
"the stored session has moved on; not writing this session's tokens over it"
|
|
);
|
|
}
|
|
}
|
|
state.settle();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl SinkState {
|
|
/// The unsaved rotation has gone where it can: drop it, and its copy in
|
|
/// the kernel keyring.
|
|
fn settle(&mut self) {
|
|
self.unsaved = None;
|
|
if std::mem::take(&mut self.kept) {
|
|
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persist rotated tokens the moment the session obtains them.
|
|
///
|
|
/// `stored_refresh` is the refresh token the keyring holds for this session
|
|
/// now. The handler re-reads the stored blob instead of capturing one, so it
|
|
/// cannot write back a stale copy over a later unlock or lock, and it writes
|
|
/// only into a blob that is still this session's (see [`TokenSink`]), so it
|
|
/// cannot resurrect a logged-out session or overwrite a newer login.
|
|
fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) -> Arc<TokenSink> {
|
|
let session_id = session.session_id().as_str();
|
|
let sink = Arc::new(TokenSink::new(session_id, stored_refresh));
|
|
TOKEN_SINKS
|
|
.lock()
|
|
.insert(session_id.to_owned(), Arc::clone(&sink));
|
|
let handler = Arc::clone(&sink);
|
|
session.http().set_on_tokens_refreshed(move |tokens| {
|
|
// A refusal is logged and retried by the sink itself.
|
|
let _ = handler.rotated(tokens);
|
|
});
|
|
sink
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn passphrases() -> KeyPassphrases {
|
|
let mut p = KeyPassphrases::new();
|
|
p.insert("key-a", b"alpha".to_vec());
|
|
p.insert("key-b", vec![0, 255, 7]);
|
|
p
|
|
}
|
|
|
|
fn blob(extra: &str) -> String {
|
|
format!(
|
|
r#"{{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
|
"refresh_token":"r","scopes":[],"password_mode":2{extra}}}"#
|
|
)
|
|
}
|
|
|
|
fn challenge() -> HumanVerification {
|
|
serde_json::from_str(
|
|
r#"{"HumanVerificationToken":"ab+c/d=","HumanVerificationMethods":["captcha","email"]}"#,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_browser_gets_the_full_verification_page() {
|
|
assert_eq!(
|
|
browser_verification_url(&challenge()),
|
|
"https://verify.proton.me/?methods=captcha,email&token=ab%2Bc%2Fd%3D"
|
|
);
|
|
}
|
|
|
|
/// The browser keeps the page's token, so the retry replays the challenge
|
|
/// itself, typed with every method the page offered.
|
|
#[test]
|
|
fn a_browser_verification_retries_with_the_challenge_token() {
|
|
let credential = browser_verified(&challenge());
|
|
assert_eq!(credential.token, "ab+c/d=");
|
|
assert_eq!(credential.method, "captcha,email");
|
|
}
|
|
|
|
#[test]
|
|
fn passphrases_survive_the_blob_encoding() {
|
|
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
|
assert_eq!(decoded, passphrases());
|
|
}
|
|
|
|
#[test]
|
|
fn passphrases_survive_the_kernel_payload_encoding() {
|
|
let decoded = decode_secret(&encode_secret(&passphrases())).expect("decodes");
|
|
assert_eq!(decoded, passphrases());
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_or_empty_secrets_read_as_absent() {
|
|
assert!(decode_secret(b"not json").is_none());
|
|
assert!(decode(&BTreeMap::new()).is_none());
|
|
assert!(decode(&BTreeMap::from([("k".into(), "!!!".into())])).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_blob_is_read_but_its_password_is_never_written_back() {
|
|
let stored: StoredSession =
|
|
serde_json::from_str(&blob(r#","mailbox_password":"hunter2""#)).unwrap();
|
|
assert_eq!(stored.mailbox_password, "hunter2");
|
|
assert!(stored.key_passphrases.is_none());
|
|
|
|
let written = serde_json::to_string(&stored).unwrap();
|
|
assert!(!written.contains("hunter2"));
|
|
assert!(!written.contains("mailbox_password"));
|
|
}
|
|
|
|
#[test]
|
|
fn locked_blob_has_no_passphrases_field() {
|
|
let stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
|
assert!(
|
|
serde_json::to_string(&stored)
|
|
.unwrap()
|
|
.find("key_passphrases")
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_passphrases_win_over_the_kernel_keyring() {
|
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
|
stored.key_passphrases = Some(encode(&passphrases()));
|
|
let (found, origin) = find_passphrases(&stored).unwrap().unwrap();
|
|
assert_eq!(origin, KeyState::Stored);
|
|
assert_eq!(found, passphrases());
|
|
}
|
|
|
|
fn tokens(access: &str, refresh: &str) -> Tokens {
|
|
Tokens {
|
|
access_token: access.into(),
|
|
refresh_token: refresh.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn own_session_tokens_are_written() {
|
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
|
assert_eq!(
|
|
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
|
Rotation::Taken
|
|
);
|
|
assert_eq!(stored.access_token, "a2");
|
|
assert_eq!(stored.refresh_token, "r2");
|
|
}
|
|
|
|
/// Issue #35: `pdfs login` stores a new session and restarts the daemon,
|
|
/// and the old daemon's stop wrote its spent tokens into the new blob.
|
|
#[test]
|
|
fn tokens_of_another_session_are_not_written() {
|
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
|
assert_eq!(
|
|
take_rotation(&mut stored, "old", "r", &tokens("a2", "r2")),
|
|
Rotation::MovedOn
|
|
);
|
|
assert_eq!(stored.refresh_token, "r");
|
|
}
|
|
|
|
/// `pdfs unlock` resumes a second session on the same login. Once it has
|
|
/// rotated, the daemon's copy of the refresh token is spent.
|
|
#[test]
|
|
fn tokens_are_not_written_over_a_newer_rotation_of_the_same_session() {
|
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
|
assert_eq!(
|
|
take_rotation(&mut stored, "s", "spent", &tokens("a2", "r2")),
|
|
Rotation::MovedOn
|
|
);
|
|
assert_eq!(stored.refresh_token, "r");
|
|
}
|
|
|
|
/// `pdfs unlock` wrote this very rotation first, from the same session.
|
|
#[test]
|
|
fn a_rotation_already_in_the_blob_is_taken() {
|
|
let mut stored = stored_with("r2");
|
|
assert_eq!(
|
|
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
|
Rotation::Taken
|
|
);
|
|
}
|
|
|
|
/// The keyring is one store per process, so the tests that use it,
|
|
/// through `keyring-core`'s in-memory store, take turns.
|
|
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
|
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
|
let turn = KEYRING.lock();
|
|
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
|
|
turn
|
|
}
|
|
|
|
fn stored_with(refresh: &str) -> StoredSession {
|
|
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() {
|
|
let _turn = mock_keyring();
|
|
write_stored(&stored_with("r")).unwrap();
|
|
|
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
|
sink.rotated(tokens("a2", "r2")).unwrap();
|
|
sink.rotated(tokens("a3", "r3")).unwrap();
|
|
assert_eq!(load().unwrap().refresh_token, "r3");
|
|
|
|
// A new login replaces the blob; the old session's next rotation, and
|
|
// its stop, leave it alone.
|
|
let mut login = stored_with("fresh");
|
|
login.session_id = "new".into();
|
|
write_stored(&login).unwrap();
|
|
sink.rotated(tokens("a4", "r4")).unwrap();
|
|
sink.flush().unwrap();
|
|
let now = load().unwrap();
|
|
assert_eq!(now.session_id, "new");
|
|
assert_eq!(now.refresh_token, "fresh");
|
|
}
|
|
|
|
/// `pdfs unlock` rotated the daemon's session behind its back: the
|
|
/// daemon's refresh token is spent, and the keyring holds the live one.
|
|
#[test]
|
|
fn a_session_rotated_by_another_process_is_newer_in_the_keyring() {
|
|
let _turn = mock_keyring();
|
|
write_stored(&stored_with("r")).unwrap();
|
|
TOKEN_SINKS
|
|
.lock()
|
|
.insert("s".into(), Arc::new(TokenSink::new("s", "r".into())));
|
|
assert!(!stored_session_is_newer());
|
|
|
|
write_stored(&stored_with("r2")).unwrap();
|
|
assert!(stored_session_is_newer());
|
|
|
|
let mut login = stored_with("fresh");
|
|
login.session_id = "new".into();
|
|
write_stored(&login).unwrap();
|
|
assert!(stored_session_is_newer());
|
|
TOKEN_SINKS.lock().remove("s");
|
|
}
|
|
|
|
/// B61: a rotation the keyring refused lived only in memory.
|
|
#[test]
|
|
fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() {
|
|
let _turn = mock_keyring();
|
|
write_stored(&stored_with("r")).unwrap();
|
|
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
|
|
|
let entry = keyring_entry().unwrap();
|
|
let cred = entry
|
|
.as_any()
|
|
.downcast_ref::<keyring_core::mock::Cred>()
|
|
.unwrap();
|
|
cred.set_error(keyring_core::Error::NoStorageAccess(
|
|
std::io::Error::other("locked").into(),
|
|
));
|
|
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
|
// Until it is written, a stop does not lose it: the next resume finds
|
|
// it in the kernel keyring, where one is offered.
|
|
let kept = sink.state.lock().kept;
|
|
if kept {
|
|
assert_eq!(
|
|
recover_unsaved(&stored_with("r")).unwrap().refresh_token,
|
|
"r2"
|
|
);
|
|
}
|
|
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while load().unwrap().refresh_token != "r2" {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"the retry never wrote the rotation"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
assert!(sink.state.lock().unsaved.is_none());
|
|
if kept {
|
|
assert!(kernelkey::UNSAVED_TOKENS.load().unwrap().is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
|
let unsaved = UnsavedRotation {
|
|
session_id: "s".into(),
|
|
spent: "r".into(),
|
|
access_token: "a2".into(),
|
|
refresh_token: "r2".into(),
|
|
};
|
|
assert!(unsaved.belongs_in(&stored_with("r")));
|
|
// Written since, by a retry or another process.
|
|
assert!(!unsaved.belongs_in(&stored_with("r2")));
|
|
let mut login = stored_with("r");
|
|
login.session_id = "new".into();
|
|
assert!(!unsaved.belongs_in(&login));
|
|
}
|
|
}
|