diff --git a/Cargo.lock b/Cargo.lock index 649ec42..cc7e7fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2036,7 +2036,7 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.1.10" +version = "0.1.11" dependencies = [ "async-trait", "base64", @@ -2061,7 +2061,7 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.1.10" +version = "0.1.11" dependencies = [ "aes-gcm 0.11.0", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 4e114cc..ce36ff4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/proton-sdk", "crates/proton-drive-rs"] [workspace.package] -version = "0.1.10" +version = "0.1.11" edition = "2024" license = "MIT" repository = "https://github.com/narrrl/proton-sdk-rs" diff --git a/crates/proton-drive-rs/Cargo.toml b/crates/proton-drive-rs/Cargo.toml index 0604b9d..f8dcca7 100644 --- a/crates/proton-drive-rs/Cargo.toml +++ b/crates/proton-drive-rs/Cargo.toml @@ -14,7 +14,7 @@ readme = "../../README.md" documentation = "https://docs.rs/proton-drive-rs" [dependencies] -proton-sdk = { path = "../proton-sdk", version = "0.1.10" } +proton-sdk = { path = "../proton-sdk", version = "0.1.11" } tokio.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/crates/proton-sdk/src/api.rs b/crates/proton-sdk/src/api.rs index c062989..77b88c7 100644 --- a/crates/proton-sdk/src/api.rs +++ b/crates/proton-sdk/src/api.rs @@ -27,6 +27,87 @@ impl ApiResponse { } } +/// The human-verification challenge attached to a [`ResponseCode::HumanVerificationRequired`] +/// response's `Details`. +/// +/// The server is not refusing the request outright: it is asking the user to +/// prove they are human and then send the same request again, carrying the +/// resulting token. Completing the challenge means loading Proton's hosted +/// verification page (see [`Self::verification_url`]) and letting the user solve +/// it; that page hands back a token which becomes a +/// [`HumanVerificationCredential`]. +#[derive(Debug, Clone, Deserialize)] +pub struct HumanVerification { + /// Opaque challenge token identifying this verification attempt. Not the + /// answer — it is the input to the verification page. + #[serde(rename = "HumanVerificationToken")] + pub token: String, + /// Verification methods the account is allowed to use, e.g. `captcha`, + /// `email`, `sms`. Order is the server's preference. + #[serde(rename = "HumanVerificationMethods", default)] + pub methods: Vec, +} + +impl HumanVerification { + /// Whether the challenge can be satisfied by a CAPTCHA, which is the only + /// method solvable entirely in-app: `email` and `sms` need a code delivered + /// out of band. + pub fn supports_captcha(&self) -> bool { + self.methods.iter().any(|m| m == "captcha") + } + + /// The hosted verification page to present to the user. + /// + /// `embed=1` asks the page for the embedded presentation (no Proton chrome), + /// which is what a webview wants; the page reports completion by posting a + /// message back to its host rather than redirecting. + pub fn verification_url(&self) -> String { + format!( + "https://verify.proton.me/?embed=1&methods={}&token={}", + self.methods.join(","), + urlencoding_minimal(&self.token), + ) + } +} + +/// Percent-encode the characters that actually occur in Proton's HV tokens and +/// would otherwise terminate or corrupt the query string. Pulling in a URL +/// dependency for one opaque token is not worth it. +fn urlencoding_minimal(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// A solved human-verification challenge, replayed on the retried request. +/// +/// The API expects the token and the method that produced it as a header pair; +/// sending the token without its type is rejected. +#[derive(Debug, Clone)] +pub struct HumanVerificationCredential { + /// The token the verification page produced — the *answer*, not the + /// challenge token from [`HumanVerification::token`]. + pub token: String, + /// The method that produced it, e.g. `captcha`. + pub method: String, +} + +impl HumanVerificationCredential { + pub fn captcha(token: impl Into) -> Self { + Self { + token: token.into(), + method: "captcha".to_owned(), + } + } +} + /// Application-level response codes returned in the `Code` field. /// /// Mirrors `Proton.Sdk.Api.ResponseCode`. Unknown / future codes deserialize to @@ -57,6 +138,10 @@ pub enum ResponseCode { OutdatedApp = 5003, Offline = 7001, IncorrectLoginCredentials = 8002, + /// The request was gated behind human verification (typically a CAPTCHA on + /// login from an unfamiliar or low-reputation IP). `Details` carries the + /// challenge — see [`HumanVerification`]. Arrives with HTTP 422. + HumanVerificationRequired = 9001, /// The access token lacks a scope the endpoint requires. Notably /// `core/v4/keys/salts` requires `locked`, which only a /// password-authenticated token carries — not one from `auth/v4/refresh`. @@ -99,6 +184,7 @@ impl ResponseCode { 5003 => Self::OutdatedApp, 7001 => Self::Offline, 8002 => Self::IncorrectLoginCredentials, + 9001 => Self::HumanVerificationRequired, 9101 => Self::InsufficientScope, 10_002 => Self::AccountDeleted, 10_003 => Self::AccountDisabled, @@ -126,3 +212,86 @@ impl<'de> Deserialize<'de> for ResponseCode { Ok(ResponseCode::from_raw(raw)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ProtonApiError; + + /// The envelope a gated login actually returns, as captured from the live + /// API. Before `9001` was mapped this deserialized to `Unknown`, which is + /// how a recoverable CAPTCHA prompt reached the user as an opaque failure. + const GATED_LOGIN: &str = r#"{ + "Code": 9001, + "Error": "For security reasons, please complete CAPTCHA.", + "Details": { + "HumanVerificationMethods": ["captcha", "email", "sms"], + "HumanVerificationToken": "abc-123_XYZ" + } + }"#; + + fn gated_error() -> ProtonApiError { + let envelope: ApiResponse = serde_json::from_str(GATED_LOGIN).unwrap(); + ProtonApiError { + code: envelope.code, + http_status: 422, + message: envelope.error_message.unwrap_or_default(), + details: envelope.details, + } + } + + #[test] + fn a_gated_login_is_recognised_rather_than_unknown() { + let envelope: ApiResponse = serde_json::from_str(GATED_LOGIN).unwrap(); + assert_eq!(envelope.code, ResponseCode::HumanVerificationRequired); + } + + #[test] + fn the_challenge_is_read_off_the_details_object() { + let hv = gated_error().human_verification().expect("challenge present"); + assert_eq!(hv.token, "abc-123_XYZ"); + assert_eq!(hv.methods, ["captcha", "email", "sms"]); + assert!(hv.supports_captcha()); + } + + /// The gate is only actionable when the server says how to satisfy it. A + /// 9001 with no `Details` must not present an empty verification page. + #[test] + fn a_challengeless_gate_yields_no_challenge() { + let mut err = gated_error(); + err.details = None; + assert!(err.is_human_verification_required()); + assert!(err.human_verification().is_none()); + } + + /// Only 9001 carries a challenge — a `Details` object on some other error + /// must not be mistaken for one. + #[test] + fn other_errors_carry_no_challenge() { + let mut err = gated_error(); + err.code = ResponseCode::AlreadyExists; + assert!(err.human_verification().is_none()); + } + + /// The token is opaque and base64-ish: `+`, `/` and `=` are all plausible in + /// it and all break a raw query string. + #[test] + fn the_challenge_token_is_escaped_into_the_url() { + let hv = HumanVerification { + token: "a+b/c=d".to_owned(), + methods: vec!["captcha".to_owned()], + }; + let url = hv.verification_url(); + assert!(url.contains("token=a%2Bb%2Fc%3Dd"), "{url}"); + assert!(url.contains("methods=captcha"), "{url}"); + } + + #[test] + fn a_captchaless_challenge_is_not_solvable_in_app() { + let hv = HumanVerification { + token: "t".to_owned(), + methods: vec!["email".to_owned(), "sms".to_owned()], + }; + assert!(!hv.supports_captcha()); + } +} diff --git a/crates/proton-sdk/src/error.rs b/crates/proton-sdk/src/error.rs index 321ed3b..1878dd1 100644 --- a/crates/proton-sdk/src/error.rs +++ b/crates/proton-sdk/src/error.rs @@ -1,6 +1,6 @@ //! Error types for the core SDK. -use crate::api::ResponseCode; +use crate::api::{HumanVerification, ResponseCode}; pub type Result = std::result::Result; @@ -63,4 +63,21 @@ impl ProtonApiError { pub fn is_insufficient_scope(&self) -> bool { matches!(self.code, ResponseCode::InsufficientScope) } + + /// The request was gated behind human verification. + pub fn is_human_verification_required(&self) -> bool { + matches!(self.code, ResponseCode::HumanVerificationRequired) + } + + /// The human-verification challenge, when this error carries one. + /// + /// `None` if the code is something else, or if the server gated the request + /// without describing how to satisfy it — which is not recoverable in-app + /// and should surface as a plain failure rather than an empty webview. + pub fn human_verification(&self) -> Option { + if !self.is_human_verification_required() { + return None; + } + serde_json::from_value(self.details.clone()?).ok() + } } diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 3a2bc78..3a40b2c 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -15,7 +15,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use tokio::sync::Mutex; -use crate::api::{ApiResponse, ResponseCode}; +use crate::api::{ApiResponse, HumanVerificationCredential, ResponseCode}; use crate::config::{API_CONTENT_TYPE, ProtonClientConfiguration, RetryPolicy}; use crate::error::{ProtonApiError, ProtonError, Result}; use crate::ids::SessionId; @@ -24,6 +24,8 @@ use crate::telemetry::{NoopTelemetry, Telemetry, TelemetryExt}; const SESSION_ID_HEADER: &str = "x-pm-uid"; const APP_VERSION_HEADER: &str = "x-pm-appversion"; const STORAGE_TOKEN_HEADER: &str = "pm-storage-token"; +const HV_TOKEN_HEADER: &str = "x-pm-human-verification-token"; +const HV_TOKEN_TYPE_HEADER: &str = "x-pm-human-verification-token-type"; /// The mutable authentication tokens for a session, shared between every /// request and the refresh path. @@ -480,6 +482,23 @@ pub async fn post_unauthenticated( config: &ProtonClientConfiguration, path: &str, body: &B, +) -> Result { + post_unauthenticated_verified(config, path, body, None).await +} + +/// [`post_unauthenticated`], optionally replaying a solved human-verification +/// challenge. +/// +/// A login from an unfamiliar IP comes back `9001` with a challenge instead of a +/// session. Once the user has solved it, the *same* request is sent again with +/// the resulting token in the header pair below — the challenge is not a +/// separate handshake, it is a precondition attached to the original call, which +/// is why this takes the credential rather than exposing a "verify" endpoint. +pub async fn post_unauthenticated_verified( + config: &ProtonClientConfiguration, + path: &str, + body: &B, + verification: Option<&HumanVerificationCredential>, ) -> Result { let http = reqwest::Client::builder() .timeout(config.request_timeout) @@ -498,6 +517,11 @@ pub async fn post_unauthenticated( if !config.user_agent.is_empty() { request = request.header(reqwest::header::USER_AGENT, &config.user_agent); } + if let Some(hv) = verification { + request = request + .header(HV_TOKEN_HEADER, &hv.token) + .header(HV_TOKEN_TYPE_HEADER, &hv.method); + } request }) .await?; diff --git a/crates/proton-sdk/src/session.rs b/crates/proton-sdk/src/session.rs index 3f7cd96..0106b05 100644 --- a/crates/proton-sdk/src/session.rs +++ b/crates/proton-sdk/src/session.rs @@ -10,6 +10,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; +use crate::api::HumanVerificationCredential; use crate::config::ProtonClientConfiguration; use crate::crypto; use crate::error::{ProtonError, Result}; @@ -94,10 +95,30 @@ impl ProtonApiSession { username: &str, password: &[u8], ) -> Result { - let init: SessionInitiationResponse = http::post_unauthenticated( + Self::begin_verified(config, username, password, None).await + } + + /// [`begin`](Self::begin), replaying a solved human-verification challenge. + /// + /// When a login is gated, the API answers `auth/v4/info` (or `auth/v4`) with + /// `9001` and a [`HumanVerification`](crate::api::HumanVerification) + /// challenge rather than a session. The caller presents that challenge, + /// collects the resulting token, and calls this — the login is *restarted* + /// from scratch with the credential attached, because the SRP handshake from + /// the gated attempt never completed and its `srp_session` is spent. + /// + /// The credential rides on both calls: the gate can be applied at either. + pub async fn begin_verified( + config: ProtonClientConfiguration, + username: &str, + password: &[u8], + verification: Option<&HumanVerificationCredential>, + ) -> Result { + let init: SessionInitiationResponse = http::post_unauthenticated_verified( &config, "auth/v4/info", &SessionInitiationRequest { username }, + verification, ) .await?; @@ -117,7 +138,7 @@ impl ProtonApiSession { crypto::DEFAULT_BIT_LENGTH, )?; - let auth: AuthenticationResponse = http::post_unauthenticated( + let auth: AuthenticationResponse = http::post_unauthenticated_verified( &config, "auth/v4", &AuthenticationRequest { @@ -126,6 +147,7 @@ impl ProtonApiSession { client_proof: BASE64.encode(&proofs.client_proof), srp_session: init.srp_session, }, + verification, ) .await?;