diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 411f8ed..99d9003 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -9,7 +9,7 @@ use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; -use proton_sdk::account::AccountClient; +use proton_sdk::account::{AccountClient, KeySalt}; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::{ @@ -174,6 +174,34 @@ impl ProtonDriveClient { } } + /// Build a Drive client whose key chain unlocks from already-known key + /// salts, so no `core/v4/keys/salts` call is made. + /// + /// That endpoint needs the `locked` scope, which a token from + /// `auth/v4/refresh` does not carry — a daemon that resumes a persisted + /// session would otherwise fail every start with a 403 once its original + /// login token has been refreshed. Capture the salts right after login via + /// [`account`](Self::account) + [`AccountClient::key_salts`], persist them + /// with the session, and pass them here on resume. + pub fn with_key_salts( + session: &ProtonApiSession, + mailbox_password: impl Into>, + key_salts: Vec, + ) -> Self { + Self { + http: session.http().with_base_route("drive/"), + account: AccountClient::with_key_salts(session, mailbox_password, key_salts), + cache: Arc::new(Mutex::new(DriveCache::default())), + entities: DriveEntityCache::new(InMemoryCacheRepository::shared()), + telemetry: NoopTelemetry::shared(), + } + } + + /// The account client backing this Drive client's key chain. + pub fn account(&self) -> &AccountClient { + &self.account + } + /// Attach a telemetry observer to receive a /// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each /// instrumented operation (transfers, navigation, mutations) plus a diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index b4b03c9..a4ab834 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -46,6 +46,7 @@ pub use client::ProtonDriveClient; pub use events::{DriveEvent, DriveEventScopeId}; pub use node::{Node, NodeKind, Thumbnail, ThumbnailType}; pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient}; +pub use proton_sdk::account::KeySalt; pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository}; pub use proton_sdk; diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs index b192f4f..d99b472 100644 --- a/crates/proton-sdk/src/account.rs +++ b/crates/proton-sdk/src/account.rs @@ -28,6 +28,8 @@ use dtos::{ UserResponse, }; +pub use dtos::KeySalt; + /// Public view of an email address attached to the account. #[derive(Debug, Clone)] pub struct Address { @@ -58,6 +60,9 @@ struct Inner { #[derive(Default)] struct Cache { + /// Raw key salts, either fetched from `core/v4/keys/salts` or seeded by the + /// caller (see [`AccountClient::with_key_salts`]). + key_salts: Option>, /// key id → passphrase derived from the mailbox password and key salts. key_passphrases: Option>>, user_keys: Option>, @@ -78,6 +83,49 @@ impl AccountClient { } } + /// Build a client with the account's key salts already known, so the key + /// chain unlocks without calling `core/v4/keys/salts`. + /// + /// That endpoint requires the `locked` scope, which only a + /// password-authenticated access token carries: a session resumed from + /// persisted tokens (or one whose token has since gone through + /// `auth/v4/refresh`) gets a 403 there. Capture the salts with + /// [`key_salts`](Self::key_salts) right after login, persist them next to + /// the session tokens, and seed them here on resume. + pub fn with_key_salts( + session: &ProtonApiSession, + mailbox_password: impl Into>, + key_salts: Vec, + ) -> Self { + Self { + inner: Arc::new(Inner { + http: session.http().clone(), + mailbox_password: mailbox_password.into(), + cache: Mutex::new(Cache { + key_salts: Some(key_salts), + ..Cache::default() + }), + }), + } + } + + /// The account's key salts, fetched (and cached) unless already seeded. + /// + /// Requires the `locked` scope — call it on a freshly password-authenticated + /// session; see [`with_key_salts`](Self::with_key_salts). + pub async fn key_salts(&self) -> Result> { + { + let cache = self.inner.cache.lock().await; + if let Some(salts) = &cache.key_salts { + return Ok(salts.clone()); + } + } + + let response: KeySaltListResponse = self.inner.http.get("core/v4/keys/salts").await?; + self.inner.cache.lock().await.key_salts = Some(response.key_salts.clone()); + Ok(response.key_salts) + } + /// All addresses on the account, ordered by their `Order` field. pub async fn addresses(&self) -> Result> { { @@ -239,9 +287,9 @@ impl AccountClient { } } - let response: KeySaltListResponse = self.inner.http.get("core/v4/keys/salts").await?; + let salts = self.key_salts().await?; let mut passphrases = HashMap::new(); - for salt in &response.key_salts { + for salt in &salts { let Some(salt_b64) = &salt.value else { continue; }; diff --git a/crates/proton-sdk/src/account/dtos.rs b/crates/proton-sdk/src/account/dtos.rs index 0763960..ba92e49 100644 --- a/crates/proton-sdk/src/account/dtos.rs +++ b/crates/proton-sdk/src/account/dtos.rs @@ -94,8 +94,17 @@ pub(super) struct KeySaltListResponse { pub key_salts: Vec, } -#[derive(Debug, Deserialize)] -pub(super) struct KeySalt { +/// A per-key bcrypt salt from `core/v4/keys/salts`. +/// +/// `core/v4/keys/salts` requires the `locked` scope, which only a +/// password-authenticated access token carries — a token obtained through +/// `auth/v4/refresh` does not, and the endpoint then answers 403 (code 9101). +/// Salts change only when the user changes their password, so callers that +/// resume a session from persisted tokens should persist the salts alongside +/// them and hand them back via +/// [`AccountClient::with_key_salts`](super::AccountClient::with_key_salts). +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +pub struct KeySalt { #[serde(rename = "ID")] pub key_id: String, /// Base64-encoded 16-byte salt; `null` for keys without a salt. diff --git a/crates/proton-sdk/src/api.rs b/crates/proton-sdk/src/api.rs index 37a1928..fdf8c9b 100644 --- a/crates/proton-sdk/src/api.rs +++ b/crates/proton-sdk/src/api.rs @@ -51,6 +51,10 @@ pub enum ResponseCode { OutdatedApp = 5003, Offline = 7001, IncorrectLoginCredentials = 8002, + /// 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`. + InsufficientScope = 9101, AccountDeleted = 10_002, AccountDisabled = 10_003, InvalidRefreshToken = 10_013, @@ -89,6 +93,7 @@ impl ResponseCode { 5003 => Self::OutdatedApp, 7001 => Self::Offline, 8002 => Self::IncorrectLoginCredentials, + 9101 => Self::InsufficientScope, 10_002 => Self::AccountDeleted, 10_003 => Self::AccountDisabled, 10_013 => Self::InvalidRefreshToken, diff --git a/crates/proton-sdk/src/error.rs b/crates/proton-sdk/src/error.rs index ab6adeb..b462470 100644 --- a/crates/proton-sdk/src/error.rs +++ b/crates/proton-sdk/src/error.rs @@ -54,4 +54,10 @@ impl ProtonApiError { pub fn is_invalid_refresh_token(&self) -> bool { matches!(self.code, ResponseCode::InvalidRefreshToken) } + + /// The token lacks a scope the endpoint requires — no amount of refreshing + /// fixes it; the user must authenticate with their password again. + pub fn is_insufficient_scope(&self) -> bool { + matches!(self.code, ResponseCode::InsufficientScope) + } }