fix(sdk) token mutex no longer spans the refresh

This commit is contained in:
nvrl committed 2026-08-16 18:53:29 +02:00
1 parent 9ec0582efe
commit 0cf760362a
10 files changed
+363 -89

No files matched your search

Generated
+41 -3
View File
@@ -1160,6 +1160,25 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1171,6 +1190,12 @@ dependencies = [
"foldhash", "foldhash",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -1277,6 +1302,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1467,6 +1493,16 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -1630,7 +1666,7 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.15.5",
] ]
[[package]] [[package]]
@@ -2036,7 +2072,7 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
@@ -2061,7 +2097,7 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
@@ -2240,6 +2276,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -2879,6 +2916,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"libc",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
+5 -2
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/proton-sdk", "crates/proton-drive-rs"] members = ["crates/proton-sdk", "crates/proton-drive-rs"]
[workspace.package] [workspace.package]
version = "0.5.1" version = "0.6.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-sdk-rs" repository = "https://github.com/narrrl/proton-sdk-rs"
@@ -24,7 +24,10 @@ opt-level = 2
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "webpki-roots", "stream", "multipart", "gzip"] } # `http2` is in reqwest's default set, which `default-features = false` drops:
# without it every concurrent block download pays its own TCP+TLS handshake
# instead of multiplexing over one connection.
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "webpki-roots", "stream", "multipart", "gzip", "http2"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_repr = "0.1" serde_repr = "0.1"
+38 -13
View File
@@ -239,6 +239,14 @@ Moving a file or folder within the same volume is an offline cryptographic opera
`proton-sdk-rs` implements **Milestone 2 (Authenticated Read/Write)** and advanced desktop features. Parity with the official C# SDK (pinned at the upstream commit documented in [UPSTREAM_SYNC.md](file:///home/narl/dev/private/proton-sdk-rs/UPSTREAM_SYNC.md)) is actively maintained. `proton-sdk-rs` implements **Milestone 2 (Authenticated Read/Write)** and advanced desktop features. Parity with the official C# SDK (pinned at the upstream commit documented in [UPSTREAM_SYNC.md](file:///home/narl/dev/private/proton-sdk-rs/UPSTREAM_SYNC.md)) is actively maintained.
The C# SDK is the parity *reference*, not the ceiling. Several subsystems have no C# public API and were
ported from the official TypeScript SDK instead, and a few surfaces exist in neither upstream — they were
built here because a real files-on-demand client (`proton-drive-linux`) needs them. Divergences are logged
per-sync in [UPSTREAM_SYNC.md](file:///home/narl/dev/private/proton-sdk-rs/UPSTREAM_SYNC.md).
**Legend** — ✅ ported from the C# SDK · 🟦 no C# public API, ported from the TypeScript SDK · ⭐ exists in
neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented.
### Feature Matrix ### Feature Matrix
| Module | Feature | Status | Notes / Upstream Parity | | Module | Feature | Status | Notes / Upstream Parity |
@@ -247,40 +255,57 @@ Moving a file or folder within the same volume is an offline cryptographic opera
| | TOTP 2FA | ✅ | Support for applying second-factor TOTP codes. | | | TOTP 2FA | ✅ | Support for applying second-factor TOTP codes. |
| | Token Refresh | ✅ | Scope refresh and transparent 401 token refresh. | | | Token Refresh | ✅ | Scope refresh and transparent 401 token refresh. |
| | Resume Session | ✅ | Restore clients instantly via serialized tokens. | | | Resume Session | ✅ | Restore clients instantly via serialized tokens. |
| **Account** | Addresses & Keys | ✅ | Address enumeration, address-key unlocking, public-key lookup by email (`AccountClient`). |
| | Key-salt Injection | ⭐ | `with_key_salts` lets a client hand in cached salts and chain an entity repository onto it, so a warm start skips the salt round-trip. |
| | Quota | ✅ | `quota()` — used/total space with `available()` / `used_fraction()` helpers. |
| **HTTP Client** | Retries & Jitter | ✅ | Exponential backoff for 408, 429, and 5xx, respecting `Retry-After`. | | **HTTP Client** | Retries & Jitter | ✅ | Exponential backoff for 408, 429, and 5xx, respecting `Retry-After`. |
| | Telemetry | ✅ | Pluggable request tracking (`ITelemetry` analogue). | | | Telemetry | ✅ | Pluggable request tracking (`ITelemetry` analogue). |
| **Drive / Volume** | Volume Resolution | ✅ | Auto-resolves volume lists; auto-creates the `my-files` root volume on first login. | | **Drive / Volume** | Volume Resolution | ✅ | Auto-resolves volume lists; auto-creates the `my-files` root volume on first login. |
| | Node Listing | ✅ | `get_node`, `enumerate_folder_children`, `enumerate_trash` with key resolution. | | | Node Listing | ✅ | `get_node`, `enumerate_folder_children`, `enumerate_trash` with key resolution. |
| | Metadata-only Listing | ⭐ | `enumerate_nodes_light` skips content-key resolution for listings that only need names and sizes. |
| | Path Helpers | ⭐ | `get_node_by_path`, `get_node_path`, `get_node_hierarchy`, `create_folder_path`, `get_available_name` — a filesystem front-end wants paths, not UID walks. |
| | File Operations | ✅ | Rename, trash, restore, delete, empty trash — batch calls report one outcome per node (`trash_nodes`), or stream them as each batch lands (`trash_nodes_streaming`). | | | File Operations | ✅ | Rename, trash, restore, delete, empty trash — batch calls report one outcome per node (`trash_nodes`), or stream them as each batch lands (`trash_nodes_streaming`). |
| | Move Operations | ✅ | Same-volume move. Batch moves are chunked automatically. | | | Move Operations | ✅ | Same-volume move. Batch moves are chunked automatically, one outcome per node. |
| | Cross-volume Move | ❌ | Not supported (throwing `NotImplementedException` in the C# public API too). | | | Cross-volume Move | ❌ | Not supported here, and not by `proton-drive-linux` either — the C# public API throws `NotImplementedException`, and a cross-volume move means re-uploading content under the destination volume's keys. |
| **Events / Sync** | Event Enumeration | ✅ | `enumerate_events` + `latest_event_id` per volume scope; `invalidate_caches_for_event` keeps the entity cache coherent. |
| | Event Manager | 🟦 | Background poll loop (`EventManager`) with a broadcast channel and a foreground/background interval switch, modelled on the TS `eventScheduler`. |
| **Uploads** | Block Uploading | ✅ | Encrypts and uploads chunks (4 MiB default) to block storage. | | **Uploads** | Block Uploading | ✅ | Encrypts and uploads chunks (4 MiB default) to block storage. |
| | Streaming Upload | ✅ | Streams uploads from any type implementing `std::io::Read`. | | | Streaming Upload | ✅ | Streams uploads from any type implementing `std::io::Read`. |
| | Revision Control | ✅ | Create drafts, upload blocks, and seal new file revisions. | | | Revision Control | ✅ | Create drafts, upload blocks, and seal new file revisions; `upload_file_replacing_draft_from` reclaims an abandoned draft. |
| | Atomic Small Uploads | ✅ | Single-request upload for small buffered files, opt-in via `with_small_file_upload` (no remote feature-flag provider in this crate). |
| | Upload Concurrency | ⭐ | `with_max_inflight_blocks` bounds in-flight block uploads/downloads per client. |
| | AEAD Block Support | ✅ | SEIPDv2 / AES-256-GCM AEAD encryption alongside legacy SEIPDv1 (AES-256-CFB). | | | AEAD Block Support | ✅ | SEIPDv2 / AES-256-GCM AEAD encryption alongside legacy SEIPDv1 (AES-256-CFB). |
| | Inline Thumbnails | ✅ | Generates and encrypts inline-signed thumbnails. | | | Inline Thumbnails | ✅ | Generates and encrypts inline-signed thumbnails. |
| **Downloads** | Block Downloading | ✅ | Streams block retrieval, decryption, and signature checks. | | **Downloads** | Block Downloading | ✅ | Streams block retrieval, decryption, and signature checks. |
| | Seekable Range Reads | ⭐ | `open_revision` → `RevisionReader::read_at` and `download_range` serve arbitrary byte ranges by fetching only the covering blocks — what makes FUSE files-on-demand possible. Neither upstream SDK has it. |
| | Thumbnail Retrieval | ✅ | `download_thumbnail`, plus the plural `enumerate_thumbnails` batch API. |
| | Content Integrity | ✅ | Verification of file manifests and block-level SHA-256 digests. | | | Content Integrity | ✅ | Verification of file manifests and block-level SHA-256 digests. |
| | Signature Verification | ✅ | Detached signature verification (non-fatal, reports status). | | | Signature Verification | ✅ | Detached signature verification (non-fatal, reports status). |
| **Revisions** | Revision History | 🟦 | `enumerate_revisions` / `get_revision`, plus `download_revision` and `download_revision_to` for any past revision. |
| | Restore & Delete | 🟦 | `restore_revision`, `delete_revision`. |
| **Devices** | Device Registration | ✅ | Create and register desktop/sync clients (`create_device`). | | **Devices** | Device Registration | ✅ | Create and register desktop/sync clients (`create_device`). |
| | Device Listing | ✅ | List registered devices and their sync root folders (`enumerate_devices`). | | | Device Listing | ✅ | List registered devices and their sync root folders (`enumerate_devices`). |
| | Device Operations | ✅ | Rename and delete registered devices. | | | Device Operations | ✅ | Rename and delete registered devices. |
| **Sharing & Links** | Member Management | ✅ | Invite users (`invite_users`), list members, update roles, and remove members. | | **Sharing & Links** | Share Creation | 🟦 | `share_node` bootstraps a share on a node (no C# public API for it). |
| | Public Share Links | ✅ | Create public shareable links (`create_public_link`) with password protection and expiry. | | | Member Management | ✅ | Invite users (`invite_users`), list members, update roles, and remove members. |
| | Public Share Links | 🟦 | Create public shareable links (`create_public_link`) with password protection and expiry; `get_public_link`, `remove_public_link`. |
| | External Invites | ✅ | Invite non-Proton users (`invite_external_users`) and track registration status. | | | External Invites | ✅ | Invite non-Proton users (`invite_external_users`) and track registration status. |
| | Bookmarks | ✅ | Save and list public link bookmarks (`create_bookmark`, `list_bookmarks`). | | | Bookmarks | ✅ | Save, list, and delete public link bookmarks (`create_bookmark`, `list_bookmarks`, `delete_bookmark`). |
| | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user. | | | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user; `leave_shared_node`. |
| | Public Link (consume) | ✅ | Open someone else's link with no Proton account (`ProtonDrivePublicLinkClient`): SRP handshake, custom passwords, browse and download the shared subtree. | | | Shared Listings | ✅ | `enumerate_shared_by_me_node_uids`, `enumerate_shared_with_me` (drive and photos volumes). |
| | Public Link streaming | ✅ | Seekable range reads over a shared file (`open_revision` → `RevisionReader::read_at`), plus thumbnails and in-place session renewal — a visitor can stream a large file without downloading it. | | | Public Link (consume) | 🟦 | Open someone else's link with no Proton account (`ProtonDrivePublicLinkClient`): SRP handshake, custom passwords, browse and download the shared subtree. |
| | Public Link streaming | ⭐ | Seekable range reads over a shared file (`open_revision` → `RevisionReader::read_at`), plus thumbnails, batch enumeration and in-place session renewal — a visitor streams a large file without downloading it. No TS or C# counterpart. |
| **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. | | **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. |
| | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). | | | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). |
| | Albums (read) | ✅ | List albums (`enumerate_album_node_uids`) and their photos (`enumerate_album`); album nodes carry `Node::album`. | | | Albums (read) | ✅ | List albums (`enumerate_album_node_uids`) and their photos (`enumerate_album`); album nodes carry `Node::album`. |
| | Photo Tags | ✅ | Add/remove classification tags (`update_photos`, or `update_photos_streaming` for per-photo outcomes as they finish); `Favorite` on photos in our own timeline. | | | Photo Tags | ✅ | Add/remove classification tags (`update_photos`, or `update_photos_streaming` for per-photo outcomes as they finish); `Favorite` on photos in our own timeline. |
| | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`). | | | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`) and by us (`enumerate_shared_node_uids`). |
| | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported. | | | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. |
| | Album Writes | ❌ | Creating albums and adding/removing photos is not ported. | | | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). |
| | Album Writes | ❌ | Creating albums and adding/removing photos — no C# public API upstream either. |
| | Photos Volume Create| ❌ | Volume creation is not yet ported. | | | Photos Volume Create| ❌ | Volume creation is not yet ported. |
| **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. | | **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. |
| **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). |
--- ---
+1 -1
View File
@@ -14,7 +14,7 @@ readme = "../../README.md"
documentation = "https://docs.rs/proton-drive-rs" documentation = "https://docs.rs/proton-drive-rs"
[dependencies] [dependencies]
proton-sdk = { path = "../proton-sdk", version = "0.5.1" } proton-sdk = { path = "../proton-sdk", version = "0.6.0" }
tokio.workspace = true tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
+51 -20
View File
@@ -3426,21 +3426,41 @@ impl ProtonDriveClient {
_ => generated.clone(), _ => generated.clone(),
}; };
// Wrap the share session key with the SRP-salted passphrase, and store the // The modulus is fetched first so that everything CPU-bound below can go
// password encrypted to the owner's address key. // to the blocking pool in one hop.
let key_salt = generate_key_salt();
let salted_passphrase = derive_key_passphrase(full_password.as_bytes(), &key_salt)?;
let share_passphrase_key_packet =
base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?);
let armored_password = address_key.encrypt(full_password.as_bytes())?;
// SRP verifier over the link password, against a fresh signed modulus.
let modulus = self.fetch_srp_modulus().await?; let modulus = self.fetch_srp_modulus().await?;
let verifier = generate_verifier(
full_password.as_bytes(), // Wrap the share session key with the SRP-salted passphrase, store the
&modulus.modulus, // password encrypted to the owner's address key, and build the SRP
DEFAULT_BIT_LENGTH, // verifier over the link password.
)?; //
// All of that is pure CPU and none of it is cheap: `derive_key_passphrase`
// is a deliberately slow bcrypt, and `generate_verifier` a 2048-bit modular
// exponentiation. Inline, they stalled every other task on the reactor
// thread for the duration — so, like the rest of the crate's PGP work, they
// run on the blocking pool.
let key_salt = generate_key_salt();
let signed_modulus = modulus.modulus.clone();
let password_bytes = full_password.clone();
let salt_for_task = key_salt;
let (share_passphrase_key_packet, armored_password, verifier) =
tokio::task::spawn_blocking(move || {
let salted_passphrase =
derive_key_passphrase(password_bytes.as_bytes(), &salt_for_task)?;
let key_packet =
base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?);
let armored_password = address_key.encrypt(password_bytes.as_bytes())?;
let verifier = generate_verifier(
password_bytes.as_bytes(),
&signed_modulus,
DEFAULT_BIT_LENGTH,
)?;
Ok::<_, ProtonError>((key_packet, armored_password, verifier))
})
.await
.map_err(|e| {
ProtonError::invalid_operation(format!("public link crypto task failed: {e}"))
})??;
let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) { let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) {
3 // random + custom password 3 // random + custom password
@@ -3868,12 +3888,23 @@ impl ProtonDriveClient {
.own_address_keys(&detail.invitation.invitee_email) .own_address_keys(&detail.invitation.invitee_email)
.await .await
.ok()?; .ok()?;
let passphrase = keys // Unlocking the share key runs its S2K — tens of milliseconds of pure
.iter() // CPU, and this is called once per invitation in a listing. Off the
.find_map(|k| k.decrypt_armored_message(&detail.share.passphrase).ok())?; // reactor, like every other key unlock in the crate
let share_key = PrivateKey::from_armored(&detail.share.share_key, &passphrase).ok()?; // (`crypto::decrypt_link_verified`).
let name_bytes = share_key.decrypt_armored_message(&detail.link.name).ok()?; let armored_passphrase = detail.share.passphrase.clone();
String::from_utf8(name_bytes).ok() let armored_share_key = detail.share.share_key.clone();
let armored_name = detail.link.name.clone();
tokio::task::spawn_blocking(move || {
let passphrase = keys
.iter()
.find_map(|k| k.decrypt_armored_message(&armored_passphrase).ok())?;
let share_key = PrivateKey::from_armored(&armored_share_key, &passphrase).ok()?;
let name_bytes = share_key.decrypt_armored_message(&armored_name).ok()?;
String::from_utf8(name_bytes).ok()
})
.await
.ok()?
} }
/// The current user's own address keys for `email`: all its private keys (for /// The current user's own address keys for `email`: all its private keys (for
+47 -17
View File
@@ -117,14 +117,26 @@ impl Credentials {
)); ));
} }
let proofs = generate_proofs( // SRP proofs are a bcrypt derivation plus 2048-bit modular
info.version, // exponentiations — tens of milliseconds of pure CPU. Inline they stalled
self.password.as_bytes(), // the reactor thread; the crate offloads its crypto everywhere else
&decode_base64(&info.url_password_salt, "UrlPasswordSalt")?, // (`crypto::decrypt_link_verified`) and this is no different.
&info.modulus, let version = info.version;
&decode_base64(&info.server_ephemeral, "ServerEphemeral")?, let password = self.password.clone();
DEFAULT_BIT_LENGTH, let url_password_salt = decode_base64(&info.url_password_salt, "UrlPasswordSalt")?;
)?; let modulus = info.modulus.clone();
let server_ephemeral = decode_base64(&info.server_ephemeral, "ServerEphemeral")?;
let proofs = blocking(move || {
Ok(generate_proofs(
version,
password.as_bytes(),
&url_password_salt,
&modulus,
&server_ephemeral,
DEFAULT_BIT_LENGTH,
)?)
})
.await?;
let request = PublicLinkAuthRequest { let request = PublicLinkAuthRequest {
client_proof: BASE64.encode(&proofs.client_proof), client_proof: BASE64.encode(&proofs.client_proof),
@@ -147,7 +159,7 @@ impl Credentials {
)); ));
} }
let share_key = self.unlock_share_key(&response)?; let share_key = self.unlock_share_key(&response).await?;
// An absent AccessToken means the server accepted an existing Proton // An absent AccessToken means the server accepted an existing Proton
// session instead of minting an anonymous one. We have no such session // session instead of minting an anonymous one. We have no such session
@@ -197,18 +209,36 @@ impl Credentials {
/// `DriveCrypto.decryptKeyWithSrpPassword`. The salt is a bcrypt salt, not /// `DriveCrypto.decryptKeyWithSrpPassword`. The salt is a bcrypt salt, not
/// the SRP one: the same `derive_key_passphrase` the create side used to /// the SRP one: the same `derive_key_passphrase` the create side used to
/// wrap this passphrase. /// wrap this passphrase.
fn unlock_share_key(&self, response: &PublicLinkAuthResponse) -> Result<PrivateKey> { /// Runs on the blocking pool: the bcrypt derivation and the share key's S2K
/// are each tens of milliseconds of CPU.
async fn unlock_share_key(&self, response: &PublicLinkAuthResponse) -> Result<PrivateKey> {
let salt = decode_base64(&response.share.share_password_salt, "SharePasswordSalt")?; let salt = decode_base64(&response.share.share_password_salt, "SharePasswordSalt")?;
let key_password = derive_key_passphrase(self.password.as_bytes(), &salt)?; let password = self.password.clone();
let passphrase = let armored_passphrase = response.share.share_passphrase.clone();
decrypt_armored_with_password(&response.share.share_passphrase, &key_password)?; let armored_share_key = response.share.share_key.clone();
Ok(PrivateKey::from_armored( blocking(move || {
&response.share.share_key, let key_password = derive_key_passphrase(password.as_bytes(), &salt)?;
&passphrase, let passphrase = decrypt_armored_with_password(&armored_passphrase, &key_password)?;
)?) Ok(PrivateKey::from_armored(&armored_share_key, &passphrase)?)
})
.await
} }
} }
/// Run CPU-bound crypto off the runtime, mapping a join failure into a
/// [`ProtonError`]. The public-link handshake is the one path in the crate whose
/// crypto is not already behind `crypto::blocking` (that helper is about link
/// decryption and takes an `AccountClient`, which a visitor has no equivalent of).
async fn blocking<T, F>(work: F) -> Result<T>
where
F: FnOnce() -> Result<T> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work).await.map_err(|e| {
ProtonError::invalid_operation(format!("public link crypto task failed: {e}"))
})?
}
/// The client a public-link session is currently issuing requests through, /// The client a public-link session is currently issuing requests through,
/// tagged with the handshake that produced it. /// tagged with the handshake that produced it.
struct LiveSession { struct LiveSession {
+3 -3
View File
@@ -38,7 +38,7 @@ pub(crate) const MAX_CONCURRENT_BLOCK_DOWNLOADS: usize = 10;
pub(crate) async fn decrypt_block_blocking( pub(crate) async fn decrypt_block_blocking(
content_key: ContentKey, content_key: ContentKey,
ciphertext: Bytes, ciphertext: Bytes,
) -> Result<Vec<u8>> { ) -> Result<Bytes> {
join_decrypt(tokio::task::spawn_blocking(move || { join_decrypt(tokio::task::spawn_blocking(move || {
content_key.decrypt_block(&ciphertext) content_key.decrypt_block(&ciphertext)
})) }))
@@ -52,7 +52,7 @@ pub(crate) async fn decrypt_block_blocking(
pub(crate) async fn digest_and_decrypt_block_blocking( pub(crate) async fn digest_and_decrypt_block_blocking(
content_key: ContentKey, content_key: ContentKey,
ciphertext: Bytes, ciphertext: Bytes,
) -> Result<(Vec<u8>, Vec<u8>)> { ) -> Result<(Vec<u8>, Bytes)> {
let handle = tokio::task::spawn_blocking(move || { let handle = tokio::task::spawn_blocking(move || {
let digest = Sha256::digest(&ciphertext).to_vec(); let digest = Sha256::digest(&ciphertext).to_vec();
content_key content_key
@@ -249,7 +249,7 @@ impl RevisionReader {
/// in-flight block slot, and dropping it here rather than at the caller /// in-flight block slot, and dropping it here rather than at the caller
/// would let `buffered` pile up decrypted blocks that nothing is accounting /// would let `buffered` pile up decrypted blocks that nothing is accounting
/// for. The caller drops it once the bytes are consumed. /// for. The caller drops it once the bytes are consumed.
async fn block_plaintext(&self, index: usize) -> Result<(Vec<u8>, OwnedSemaphorePermit)> { async fn block_plaintext(&self, index: usize) -> Result<(Bytes, OwnedSemaphorePermit)> {
// Held across fetch *and* decrypt: both halves are resident 4 MiB // Held across fetch *and* decrypt: both halves are resident 4 MiB
// buffers, and the plaintext outlives the ciphertext. // buffers, and the plaintext outlives the ciphertext.
let permit = self let permit = self
+39 -18
View File
@@ -26,6 +26,7 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use futures::stream::{self, StreamExt, TryStreamExt};
use proton_sdk::error::{ProtonError, Result}; use proton_sdk::error::{ProtonError, Result};
use proton_sdk::http::ApiHttpClient; use proton_sdk::http::ApiHttpClient;
use proton_sdk::ids::{LinkId, VolumeId}; use proton_sdk::ids::{LinkId, VolumeId};
@@ -143,6 +144,18 @@ impl RevisionTransport {
} }
/// The revision's metadata and its full, ordered block table. /// The revision's metadata and its full, ordered block table.
///
/// Pages after the first are fetched several at a time rather than one per
/// round trip: a 4 GiB file is 21 pages, and walking them serially costs 21
/// RTTs before the first byte of content can be requested. The window is
/// speculative — a page's `FromBlockIndex` is derived from its position
/// rather than from the previous page's contents, which the contiguity check
/// below already requires to hold — so a short page inside a batch simply
/// ends the walk and the surplus responses are discarded.
///
/// The window ramps `2 → 4 → …→ MAX_PAGE_WINDOW` so that a file only just
/// over a page boundary wastes one speculative request rather than five,
/// while a genuinely large table still reaches full width after two batches.
pub(crate) async fn list_blocks( pub(crate) async fn list_blocks(
&self, &self,
volume_id: &VolumeId, volume_id: &VolumeId,
@@ -150,29 +163,39 @@ impl RevisionTransport {
revision_id: &str, revision_id: &str,
) -> Result<(RevisionDto, Vec<BlockDto>)> { ) -> Result<(RevisionDto, Vec<BlockDto>)> {
const PAGE_SIZE: i32 = 50; const PAGE_SIZE: i32 = 50;
const MAX_PAGE_WINDOW: usize = 6;
let mut blocks: Vec<BlockDto> = Vec::new(); let page = |page_index: i32| {
let mut metadata: Option<RevisionDto> = None; let from_index = page_index * PAGE_SIZE + 1;
let mut from_index: i32 = 1;
loop {
let path = format!( let path = format!(
"v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}?FromBlockIndex={from_index}&PageSize={PAGE_SIZE}&NoBlockUrls=0" "v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}?FromBlockIndex={from_index}&PageSize={PAGE_SIZE}&NoBlockUrls=0"
); );
let response: RevisionResponse = self.get(&path).await?; async move { self.get::<RevisionResponse>(&path).await }
let mut revision = response.revision; };
let page = std::mem::take(&mut revision.blocks);
let page_len = page.len();
if metadata.is_none() { let mut first = page(0).await?.revision;
metadata = Some(revision); let mut blocks: Vec<BlockDto> = std::mem::take(&mut first.blocks);
} let mut complete = blocks.len() < PAGE_SIZE as usize;
blocks.extend(page); let metadata = first;
if page_len < PAGE_SIZE as usize { let mut next_page: i32 = 1;
break; let mut window: usize = 2;
while !complete {
let batch: Vec<i32> = (next_page..next_page + window as i32).collect();
next_page += window as i32;
let mut responses = stream::iter(batch.into_iter().map(page)).buffered(window);
window = (window * 2).min(MAX_PAGE_WINDOW);
while let Some(response) = responses.try_next().await? {
if complete {
// A page in this batch already came up short; anything after
// it is past the end of the block table.
continue;
}
let page_blocks = response.revision.blocks;
complete = page_blocks.len() < PAGE_SIZE as usize;
blocks.extend(page_blocks);
} }
from_index = blocks.iter().map(|b| b.index).max().unwrap_or(from_index) + 1;
} }
blocks.sort_by_key(|b| b.index); blocks.sort_by_key(|b| b.index);
@@ -184,8 +207,6 @@ impl RevisionTransport {
} }
} }
let metadata = metadata
.ok_or_else(|| ProtonError::invalid_operation("revision returned no metadata"))?;
Ok((metadata, blocks)) Ok((metadata, blocks))
} }
} }
+7 -2
View File
@@ -8,6 +8,7 @@
use std::io::Cursor; use std::io::Cursor;
use bytes::Bytes;
use pgp::armor::Dearmor; use pgp::armor::Dearmor;
use pgp::composed::{MessageBuilder, PlainSessionKey, RawSessionKey}; use pgp::composed::{MessageBuilder, PlainSessionKey, RawSessionKey};
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize}; use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
@@ -347,7 +348,7 @@ impl ContentKey {
/// rejects a message that *starts* with a SEIPD packet, so we parse packets /// rejects a message that *starts* with a SEIPD packet, so we parse packets
/// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full /// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full
/// round-trip message), and decrypt it with the supplied session key. /// round-trip message), and decrypt it with the supplied session key.
pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> { pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result<Bytes, CryptoError> {
let inner = self.decrypt_seipd(ciphertext)?; let inner = self.decrypt_seipd(ciphertext)?;
// A content block's payload is a single literal-data packet. // A content block's payload is a single literal-data packet.
@@ -357,7 +358,11 @@ impl ContentKey {
.ok_or_else(|| CryptoError::Parse("block payload is empty".into()))? .ok_or_else(|| CryptoError::Parse("block payload is empty".into()))?
.map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?; .map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?;
match literal { match literal {
Packet::LiteralData(data) => Ok(data.data().to_vec()), // `into_bytes`, not `data().to_vec()`: the literal packet already
// owns its payload as `Bytes`, and a block is 4 MiB — copying it out
// doubled the peak footprint of every block delivered, times the ten
// a download has in flight.
Packet::LiteralData(data) => Ok(data.into_bytes()),
other => Err(CryptoError::Parse(format!( other => Err(CryptoError::Parse(format!(
"block payload is not literal data: {:?}", "block payload is not literal data: {:?}",
other.tag() other.tag()
+131 -10
View File
@@ -68,7 +68,19 @@ struct Inner {
base_url: String, base_url: String,
config: ProtonClientConfiguration, config: ProtonClientConfiguration,
session_id: SessionId, session_id: SessionId,
tokens: Mutex<Tokens>, /// The current tokens, read on every request and replaced by a refresh.
///
/// A plain `std::sync::RwLock` over an `Arc` snapshot, deliberately *not*
/// the tokio mutex it used to be: reading the access token is what every
/// ordinary request does, and holding an async mutex for it meant queueing
/// behind whoever was refreshing — a refresh that stalls on the network (up
/// to the retry policy's whole budget) stalled every other API call with it.
/// The lock here is only ever held for a clone of an `Arc`, never across an
/// await; the mutual exclusion a refresh needs lives in [`Inner::refresh`].
tokens: std::sync::RwLock<Arc<Tokens>>,
/// Serializes token refreshes. Held across the refresh network call, so it
/// must be the async mutex; readers of `tokens` never touch it.
refresh: Mutex<()>,
/// Telemetry sink for per-request events. Interior-mutable because the /// Telemetry sink for per-request events. Interior-mutable because the
/// client is already shared (cloned into the Drive client) by the time a /// client is already shared (cloned into the Drive client) by the time a
/// caller attaches a sink via [`ApiHttpClient::set_telemetry`]. Defaults to /// caller attaches a sink via [`ApiHttpClient::set_telemetry`]. Defaults to
@@ -103,7 +115,8 @@ impl ApiHttpClient {
base_url, base_url,
config, config,
session_id, session_id,
tokens: Mutex::new(tokens), tokens: std::sync::RwLock::new(Arc::new(tokens)),
refresh: Mutex::new(()),
telemetry: std::sync::Mutex::new(NoopTelemetry::shared()), telemetry: std::sync::Mutex::new(NoopTelemetry::shared()),
on_tokens_refreshed: std::sync::Mutex::new(None), on_tokens_refreshed: std::sync::Mutex::new(None),
}), }),
@@ -144,7 +157,16 @@ impl ApiHttpClient {
/// Snapshot the current tokens (e.g. to persist for a later `resume`). /// Snapshot the current tokens (e.g. to persist for a later `resume`).
pub async fn current_tokens(&self) -> Tokens { pub async fn current_tokens(&self) -> Tokens {
self.inner.tokens.lock().await.clone() (*self.tokens()).clone()
}
/// The current tokens, as a cheap `Arc` snapshot. Never held across an await.
fn tokens(&self) -> Arc<Tokens> {
self.inner
.tokens
.read()
.expect("tokens rwlock poisoned")
.clone()
} }
/// Attach a telemetry sink to receive a per-request /// Attach a telemetry sink to receive a per-request
@@ -313,7 +335,7 @@ impl ApiHttpClient {
let metadata = serde_json::to_vec(metadata)?; let metadata = serde_json::to_vec(metadata)?;
let mut timer = self.telemetry().start("http_request"); let mut timer = self.telemetry().start("http_request");
timer.attr("method", "POST"); timer.attr("method", "POST");
let rejected_token = self.inner.tokens.lock().await.access_token.clone(); let rejected_token = self.tokens().access_token.clone();
let response = self let response = self
.send_multipart_with_token(path, &metadata, binary_parts, &rejected_token) .send_multipart_with_token(path, &metadata, binary_parts, &rejected_token)
.await?; .await?;
@@ -371,7 +393,7 @@ impl ApiHttpClient {
let mut timer = self.telemetry().start("http_request"); let mut timer = self.telemetry().start("http_request");
timer.attr("method", method.as_str()); timer.attr("method", method.as_str());
let access_token = self.inner.tokens.lock().await.access_token.clone(); let access_token = self.tokens().access_token.clone();
// An early `?` here records the op as a failure (OpTimer defaults to it). // An early `?` here records the op as a failure (OpTimer defaults to it).
let response = self let response = self
@@ -501,14 +523,19 @@ impl ApiHttpClient {
/// in-memory access token already differs from the rejected one, another /// in-memory access token already differs from the rejected one, another
/// task refreshed first and we reuse its result. /// task refreshed first and we reuse its result.
async fn refresh_access_token(&self, rejected_access_token: &str) -> Result<String> { async fn refresh_access_token(&self, rejected_access_token: &str) -> Result<String> {
let mut guard = self.inner.tokens.lock().await; // The refresh lock, not the token lock: ordinary requests read the
// tokens without ever waiting on this.
let _guard = self.inner.refresh.lock().await;
if guard.access_token != rejected_access_token { // Re-read under the refresh lock — whoever held it before us may have
return Ok(guard.access_token.clone()); // already replaced the token we were handed a 401 for.
let current = self.tokens();
if current.access_token != rejected_access_token {
return Ok(current.access_token.clone());
} }
let refreshed = self.request_refresh(&guard.refresh_token).await?; let refreshed = self.request_refresh(&current.refresh_token).await?;
*guard = refreshed.clone(); *self.inner.tokens.write().expect("tokens rwlock poisoned") = Arc::new(refreshed.clone());
// Notify callback // Notify callback
if let Some(ref cb) = *self if let Some(ref cb) = *self
@@ -1027,4 +1054,98 @@ mod tests {
"an ordinary session keeps refreshing across a route change" "an ordinary session keeps refreshing across a route change"
); );
} }
/// An ordinary request reads the access token without queueing behind an
/// in-flight refresh.
///
/// This is the property the token lock exists to have: the tokens were once
/// behind a tokio `Mutex` that the refresh path held across its network
/// call, so one slow `auth/v4/refresh` — up to the whole retry budget —
/// stalled every other API call on the session. The refresh here is made
/// deliberately slow; the concurrent GET has to finish long before it.
#[tokio::test]
async fn an_ordinary_request_does_not_wait_for_an_in_flight_refresh() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const REFRESH_DELAY: Duration = Duration::from_millis(1500);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// `/needs-refresh` 401s once, which sends the caller to
// `auth/v4/refresh` — and that reply is held back. Everything else is
// answered immediately.
let unauthorized = Arc::new(AtomicUsize::new(0));
let seen = unauthorized.clone();
let server = tokio::spawn(async move {
loop {
let (mut sock, _) = listener.accept().await.unwrap();
let seen = seen.clone();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let n = sock.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..n]).into_owned();
let (status, body) = if request.contains("auth/v4/refresh") {
tokio::time::sleep(REFRESH_DELAY).await;
(
"200 OK",
r#"{"Code":1000,"AccessToken":"fresh","RefreshToken":"fresh-refresh","UID":"test-session"}"#.to_string(),
)
} else if request.contains("needs-refresh")
&& seen.fetch_add(1, Ordering::SeqCst) == 0
{
// Only the first attempt 401s; the retry after the
// refresh succeeds, as it would against the real API.
("401 Unauthorized", r#"{"Code":401}"#.to_string())
} else {
("200 OK", r#"{"Code":1000}"#.to_string())
};
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
sock.write_all(head.as_bytes()).await.unwrap();
sock.write_all(body.as_bytes()).await.unwrap();
sock.flush().await.unwrap();
});
}
});
let config = ProtonClientConfiguration::new("test@1.0")
.with_base_url(format!("http://{addr}/"))
.with_retry_policy(RetryPolicy::disabled());
let client = ApiHttpClient::new(
config,
SessionId::from("test-session"),
Tokens {
access_token: "access".into(),
refresh_token: "refresh".into(),
},
)
.unwrap();
let refreshing = tokio::spawn({
let client = client.clone();
async move { client.get::<ApiResponse>("needs-refresh").await }
});
// Give the refresh time to be issued and be sitting on the network.
tokio::time::sleep(Duration::from_millis(200)).await;
let started = Instant::now();
let _: ApiResponse = client.get("ordinary").await.unwrap();
let waited = started.elapsed();
assert!(
waited < REFRESH_DELAY / 2,
"an ordinary request waited {waited:?} on a refresh still in flight"
);
refreshing.await.unwrap().unwrap();
server.abort();
}
} }