From 92772e0ef04350524283f84a6c0b52685d7b28a6 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 15 Oct 2026 22:57:57 +0200 Subject: [PATCH] fix(drive): replace a locked My Files volume instead of failing on it A password reset gives the account new keys and locks the volume whose share was encrypted to the old ones. v2/shares/my-files still returns that share, so decrypting its passphrase failed with "missing key" on every call and a client could never reach My Files (proton-drive-linux#40). The web client handles this by listing volumes and creating a new one when no active regular volume is left. The locked volume stays restorable from the web. ensure_my_files now does the same when the share key fails to decrypt, and only once GET volumes confirms there is no active regular volume. Any other decryption failure is returned as before. --- crates/proton-drive-rs/src/client.rs | 82 ++++++++++++++++++++++++---- crates/proton-drive-rs/src/dtos.rs | 19 +++++++ 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index a399574..34a63cd 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -76,7 +76,7 @@ use crate::dtos::{ ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, - VolumeEventListResponse, VolumeTrashResponse, + VolumeEventListResponse, VolumeListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ @@ -5452,7 +5452,7 @@ impl ProtonDriveClient { // Mirrors C# `NodeOperations.GetOrCreateMyFilesFolderAsync`: a brand-new // account has no My Files volume yet, so the share lookup fails. Create // the volume, then re-read it through the normal path to populate caches. - let response: MyFilesShareResponse = match self.http.get("v2/shares/my-files").await { + let mut response: MyFilesShareResponse = match self.http.get("v2/shares/my-files").await { Ok(response) => response, Err(e) if is_not_found(&e) => { self.create_volume().await?; @@ -5460,11 +5460,30 @@ impl ProtonDriveClient { } Err(e) => return Err(e), }; + + // A password reset replaces the account's keys and locks the volume + // whose share was encrypted to the old ones; its key then fails with + // "missing key" on every call. The web client answers a missing + // *active* regular volume by creating a fresh one (the locked one stays + // restorable from the web), so do the same, but only once the server + // confirms no active regular volume is left, never on a guess. + let share_key = match decrypt_share_key(&self.account, &response.share).await { + Ok(key) => key, + Err(e @ ProtonError::Crypto(_)) => { + let volumes: VolumeListResponse = self.http.get("volumes").await?; + if has_active_regular_volume(&volumes) { + return Err(e); + } + tracing::warn!(error = %e, "My Files volume is locked; creating a new one"); + self.create_volume().await?; + response = self.http.get("v2/shares/my-files").await?; + decrypt_share_key(&self.account, &response.share).await? + } + Err(e) => return Err(e), + }; let volume_id = response.volume.id.clone(); let share_id = response.share.id.clone(); - let share_key = decrypt_share_key(&self.account, &response.share).await?; - // The My Files root link's parent key is the share key. let root_link = &response.link.link; let root_uid = NodeUid::new(volume_id.clone(), root_link.id.clone()); @@ -7861,6 +7880,17 @@ fn is_listable_revision_state(state: Option) -> bool { matches!(state, Some(1) | Some(2)) } +/// Whether the user still has an active regular (My Files) volume. Mirrors the +/// web client's check before it creates a replacement for a locked one: `Type` +/// 1 is regular, `State` 1 is active. A volume without a `Type` counts as +/// regular, since older responses omit it and Photos volumes always carry it. +fn has_active_regular_volume(response: &VolumeListResponse) -> bool { + response + .volumes + .iter() + .any(|v| v.state == 1 && v.volume_type.unwrap_or(1) == 1) +} + /// Whether an API error means the requested entity simply is not there. /// /// Both `DoesNotExist` in the envelope and a bare HTTP 404 occur in the wild for @@ -8702,16 +8732,16 @@ mod tests { CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, MAX_NODE_NAME_LENGTH, NodeAction, ParentKeyId, aggregate_outcomes, alternate_file_names, alternate_folder_names, assemble_manifest, context_share_path, drive_items, - epoch_to_iso8601, is_expired_upload_token, is_listable_revision_state, is_upload_timeout, - join_node_path, missing_related_links, path_segments, record_timeline_outcome, - resolve_parent_keys, run_context_share_mutation, share_membership_from_dto, - small_upload_applicable, small_upload_should_fall_back, take_block_target, - take_thumbnail_target, to_drive_event, validate_node_name, + epoch_to_iso8601, has_active_regular_volume, is_expired_upload_token, + is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, + path_segments, record_timeline_outcome, resolve_parent_keys, run_context_share_mutation, + share_membership_from_dto, small_upload_applicable, small_upload_should_fall_back, + take_block_target, take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair, ShareMembershipSummaryDto, SharedWithMeLinkDto, SharedWithMeResponse, VolumeEventDto, - VolumeEventLinkDto, + VolumeEventLinkDto, VolumeListResponse, }; use crate::node::RevisionState; use crate::sharing::MemberRole; @@ -9630,4 +9660,36 @@ mod tests { assert_eq!(key_for(&keys, &links[1]), None); assert_eq!(key_for(&keys, &links[2]), Some("share-key:c")); } + + fn volumes(json: &str) -> VolumeListResponse { + serde_json::from_str(json).expect("volume list parses") + } + + #[test] + fn a_locked_main_volume_is_not_an_active_regular_one() { + // After a password reset: the old regular volume is locked (3), and + // an active Photos volume (2) does not stand in for My Files. + let locked = volumes( + r#"{"Code":1000,"Volumes":[ + {"VolumeID":"v1","State":3,"Type":1}, + {"VolumeID":"v2","State":1,"Type":2} + ]}"#, + ); + assert!(!has_active_regular_volume(&locked)); + assert!(!has_active_regular_volume(&volumes(r#"{"Volumes":[]}"#))); + } + + #[test] + fn an_active_regular_volume_is_found_with_or_without_a_type() { + let typed = volumes( + r#"{"Volumes":[ + {"VolumeID":"v1","State":3,"Type":1}, + {"VolumeID":"v3","State":1,"Type":1} + ]}"#, + ); + assert!(has_active_regular_volume(&typed)); + assert!(has_active_regular_volume(&volumes( + r#"{"Volumes":[{"VolumeID":"v1","State":1}]}"# + ))); + } } diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index d8a0edb..6f01dae 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -41,6 +41,25 @@ pub struct ShareDto { pub address_id: AddressId, } +/// `GET volumes`: every volume of the user, active or not. +#[derive(Debug, Deserialize)] +pub struct VolumeListResponse { + #[serde(rename = "Volumes")] + pub volumes: Vec, +} + +/// One entry of [`VolumeListResponse`]. `Type` is 1 for a regular (My Files) +/// volume and 2 for Photos; `State` is 1 when active and 3 when locked. +#[derive(Debug, Deserialize)] +pub struct VolumeListItemDto { + #[serde(rename = "VolumeID")] + pub id: VolumeId, + #[serde(rename = "State")] + pub state: i32, + #[serde(rename = "Type", default)] + pub volume_type: Option, +} + /// `POST v2/volumes/{vid}/links` request body. #[derive(Debug, Serialize)] pub struct LinkDetailsRequest<'a> {