added persistent key salts

This commit is contained in:
nvrl committed 2026-07-13 10:26:12 +02:00
1 parent 2e3c23803e
commit a24785d1a9
6 files changed
+102 -5

No files matched your search

+50 -2
View File
@@ -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<Vec<KeySalt>>,
/// key id → passphrase derived from the mailbox password and key salts.
key_passphrases: Option<HashMap<String, Vec<u8>>>,
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.
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();
for salt in &response.key_salts {
for salt in &salts {
let Some(salt_b64) = &salt.value else {
continue;
};
+11 -2
View File
@@ -94,8 +94,17 @@ pub(super) struct KeySaltListResponse {
pub key_salts: Vec<KeySalt>,
}
#[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.
+5
View File
@@ -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,
+6
View File
@@ -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)
}
}