mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
added re captcha flow
This commit is contained in:
1 parent
8c44768010
commit
75a615dd28
7 files changed
+240
-8
No files matched your search
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Error types for the core SDK.
|
||||
|
||||
use crate::api::ResponseCode;
|
||||
use crate::api::{HumanVerification, ResponseCode};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ProtonError>;
|
||||
|
||||
@@ -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<HumanVerification> {
|
||||
if !self.is_human_verification_required() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_value(self.details.clone()?).ok()
|
||||
}
|
||||
}
|
||||
@@ -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<B: Serialize, T: DeserializeOwned>(
|
||||
config: &ProtonClientConfiguration,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
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<B: Serialize, T: DeserializeOwned>(
|
||||
config: &ProtonClientConfiguration,
|
||||
path: &str,
|
||||
body: &B,
|
||||
verification: Option<&HumanVerificationCredential>,
|
||||
) -> Result<T> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(config.request_timeout)
|
||||
@@ -498,6 +517,11 @@ pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
|
||||
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?;
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Self> {
|
||||
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?;
|
||||
|
||||
|
||||
Reference in new issue
Block a user