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.
This commit is contained in:
nvrl committed 2026-10-15 22:57:57 +02:00
1 parent eb0e5aaf57
commit 92772e0ef0
2 files changed
+91 -10

No files matched your search

+72 -10
View File
@@ -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<i32>) -> 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}]}"#
)));
}
}
+19
View File
@@ -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<VolumeListItemDto>,
}
/// 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<i32>,
}
/// `POST v2/volumes/{vid}/links` request body.
#[derive(Debug, Serialize)]
pub struct LinkDetailsRequest<'a> {