mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
added persistent key salts
This commit is contained in:
1 parent
2e3c23803e
commit
a24785d1a9
6 files changed
+102
-5
No files matched your search
@@ -9,7 +9,7 @@ use base64::engine::general_purpose::STANDARD as BASE64;
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use proton_sdk::account::AccountClient;
|
use proton_sdk::account::{AccountClient, KeySalt};
|
||||||
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
|
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
|
||||||
use proton_sdk::crypto::PrivateKey;
|
use proton_sdk::crypto::PrivateKey;
|
||||||
use proton_sdk::crypto::{
|
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<Vec<u8>>,
|
||||||
|
key_salts: Vec<KeySalt>,
|
||||||
|
) -> 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
|
/// Attach a telemetry observer to receive a
|
||||||
/// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each
|
/// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each
|
||||||
/// instrumented operation (transfers, navigation, mutations) plus a
|
/// instrumented operation (transfers, navigation, mutations) plus a
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub use client::ProtonDriveClient;
|
|||||||
pub use events::{DriveEvent, DriveEventScopeId};
|
pub use events::{DriveEvent, DriveEventScopeId};
|
||||||
pub use node::{Node, NodeKind, Thumbnail, ThumbnailType};
|
pub use node::{Node, NodeKind, Thumbnail, ThumbnailType};
|
||||||
pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient};
|
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::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
|
||||||
|
|
||||||
pub use proton_sdk;
|
pub use proton_sdk;
|
||||||
@@ -28,6 +28,8 @@ use dtos::{
|
|||||||
UserResponse,
|
UserResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use dtos::KeySalt;
|
||||||
|
|
||||||
/// Public view of an email address attached to the account.
|
/// Public view of an email address attached to the account.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Address {
|
pub struct Address {
|
||||||
@@ -58,6 +60,9 @@ struct Inner {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Cache {
|
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<Vec<KeySalt>>,
|
||||||
/// key id → passphrase derived from the mailbox password and key salts.
|
/// key id → passphrase derived from the mailbox password and key salts.
|
||||||
key_passphrases: Option<HashMap<String, Vec<u8>>>,
|
key_passphrases: Option<HashMap<String, Vec<u8>>>,
|
||||||
user_keys: Option<Vec<PrivateKey>>,
|
user_keys: Option<Vec<PrivateKey>>,
|
||||||
@@ -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<Vec<u8>>,
|
||||||
|
key_salts: Vec<KeySalt>,
|
||||||
|
) -> 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<Vec<KeySalt>> {
|
||||||
|
{
|
||||||
|
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.
|
/// All addresses on the account, ordered by their `Order` field.
|
||||||
pub async fn addresses(&self) -> Result<Vec<Address>> {
|
pub async fn addresses(&self) -> Result<Vec<Address>> {
|
||||||
{
|
{
|
||||||
@@ -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();
|
let mut passphrases = HashMap::new();
|
||||||
for salt in &response.key_salts {
|
for salt in &salts {
|
||||||
let Some(salt_b64) = &salt.value else {
|
let Some(salt_b64) = &salt.value else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -94,8 +94,17 @@ pub(super) struct KeySaltListResponse {
|
|||||||
pub key_salts: Vec<KeySalt>,
|
pub key_salts: Vec<KeySalt>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
/// A per-key bcrypt salt from `core/v4/keys/salts`.
|
||||||
pub(super) struct KeySalt {
|
///
|
||||||
|
/// `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")]
|
#[serde(rename = "ID")]
|
||||||
pub key_id: String,
|
pub key_id: String,
|
||||||
/// Base64-encoded 16-byte salt; `null` for keys without a salt.
|
/// Base64-encoded 16-byte salt; `null` for keys without a salt.
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ pub enum ResponseCode {
|
|||||||
OutdatedApp = 5003,
|
OutdatedApp = 5003,
|
||||||
Offline = 7001,
|
Offline = 7001,
|
||||||
IncorrectLoginCredentials = 8002,
|
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,
|
AccountDeleted = 10_002,
|
||||||
AccountDisabled = 10_003,
|
AccountDisabled = 10_003,
|
||||||
InvalidRefreshToken = 10_013,
|
InvalidRefreshToken = 10_013,
|
||||||
@@ -89,6 +93,7 @@ impl ResponseCode {
|
|||||||
5003 => Self::OutdatedApp,
|
5003 => Self::OutdatedApp,
|
||||||
7001 => Self::Offline,
|
7001 => Self::Offline,
|
||||||
8002 => Self::IncorrectLoginCredentials,
|
8002 => Self::IncorrectLoginCredentials,
|
||||||
|
9101 => Self::InsufficientScope,
|
||||||
10_002 => Self::AccountDeleted,
|
10_002 => Self::AccountDeleted,
|
||||||
10_003 => Self::AccountDisabled,
|
10_003 => Self::AccountDisabled,
|
||||||
10_013 => Self::InvalidRefreshToken,
|
10_013 => Self::InvalidRefreshToken,
|
||||||
|
|||||||
@@ -54,4 +54,10 @@ impl ProtonApiError {
|
|||||||
pub fn is_invalid_refresh_token(&self) -> bool {
|
pub fn is_invalid_refresh_token(&self) -> bool {
|
||||||
matches!(self.code, ResponseCode::InvalidRefreshToken)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user