feat(drive,sdk): port upstream C# SDK up to ce000edd

- Moves go through `move-multiple` in batches of 150, with an optional
  target name per node (`move_nodes_streaming` / `NodeMoveItem`), a single
  `InvalidRequirements` retry against the server's current name hash, and
  passphrase signing for anonymously created nodes. A batch rejected as a
  whole keeps its API error code on every node.
- Share memberships carry invite time and inviter email, and the inviter's
  `drive.share-member.inviter` signature is verified as non-fatal metadata
  (`verify_detached_with_context`).
- `Node` gains `direct_role` and `share_id`.
- Trashed detection reads `TrashTime` as well as the legacy `Trashed`.

Breaking: new public struct fields on `Node` and `ShareMembership`.
This commit is contained in:
nvrl committed 2026-09-15 13:54:04 +02:00
1 parent 81844cb9da
commit d72065418b
11 files changed
+667 -198

No files matched your search

+3 -2
View File
@@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed.
- **Upstream**: https://github.com/ProtonDriveApps/sdk - **Upstream**: https://github.com/ProtonDriveApps/sdk
- **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the
mid-2026 projects reorg; the script watches both) mid-2026 projects reorg; the script watches both)
- **Pinned**: `c8d03244938a6b4d107c755df8904d7d971ed1c2` - **Pinned**: `ce000edd053768bf8fd2da04f3ef4fa68ba9e71e`
- **Date**: 2026-08-27 - **Date**: 2026-09-15
> **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer > **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer
> exist in its history. Triage by commit subject/date when a pin goes stale. > exist in its history. Triage by commit subject/date when a pin goes stale.
@@ -37,3 +37,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
| 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. | | 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. |
| 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. | | 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. |
| 2026-08-27 | `df6b6bfd` → `c8d03244` | `e0e198e2`, `c8d03244` | ported: **save photos to the timeline** (`ProtonPhotosClient::save_photos_to_timeline` — C# `PhotoOperations.SavePhotosToTimelineAsync`, itself the JS `saveToTimeline`). Photos already on our photos volume are *moved* into the timeline root through `PUT photos/volumes/{vid}/links/transfer-multiple`, batched so no request carries more than ten links (main photo + related photos counted together, never split across requests); photos on another volume — from a shared-with-me album — are *copied* through `POST volumes/{vid}/links/{lid}/copy`, one request each, with their related photos nested in `Photos`. This closes the **cross-volume copy** hole logged in the 2026-08-18 row (TS `copyPhoto`), so photos from albums shared with us can now be kept. The re-encryption is what `add_photos_to_album` already did — name re-encrypted to the target key and signed by the photos membership address, name/content hashes recomputed under the target hash key, node passphrase **rewrapped** via `rewrap_message_to` so the original `NodePassphraseSignature` survives — so it was lifted into a shared `photo_target_payload` (C# `PhotosCrypto.EncryptPhotoForTarget` + `PhotoTransferPayloadBuilder`) that both paths build on. Retry semantics mirror upstream exactly: a photo the server reports an incomplete group for is re-queued **once** with the related photos it named, and only its second answer becomes an outcome; a photo already parented to the timeline root fails its own outcome. Also ported: `ProtonPhotosClient::enumerate_events` (`c8d03244`'s only non-interop hunk — a passthrough to the Drive client's, which we already had). diverged deliberately: upstream returns `IAsyncEnumerable<NodeActionResult>`; ours collects `Vec<(NodeUid, Result<()>)>` like `move_nodes` and the other batch ops — and, because of the retry queue, outcome order follows completion, not input, which the API docs state. not ported: `29e58378` **per-file upload performance metrics** (`UploadPerformanceEvent`, size/route/block-count classification, active-vs-paused attempt timing on `UploadController`) — it is telemetry plumbing for C#'s interop metric callback; our `proton-sdk::telemetry` has its own `TelemetryEvent`/`OpTimer` shape and we have no pause/resume upload controller, and nothing about the upload itself changes. structural, not ported: `0537d906` (C# merges its batch loaders and `DtoToMetadataConverter` into one `NodeProvider`; the only behavior-adjacent bit is dropping the cache's claim/acquisition dance, which our single-flight already covers). Live validation pending: the whole path needs a photos-volume account, and the cross-volume half needs a second account sharing an album with it. | | 2026-08-27 | `df6b6bfd` → `c8d03244` | `e0e198e2`, `c8d03244` | ported: **save photos to the timeline** (`ProtonPhotosClient::save_photos_to_timeline` — C# `PhotoOperations.SavePhotosToTimelineAsync`, itself the JS `saveToTimeline`). Photos already on our photos volume are *moved* into the timeline root through `PUT photos/volumes/{vid}/links/transfer-multiple`, batched so no request carries more than ten links (main photo + related photos counted together, never split across requests); photos on another volume — from a shared-with-me album — are *copied* through `POST volumes/{vid}/links/{lid}/copy`, one request each, with their related photos nested in `Photos`. This closes the **cross-volume copy** hole logged in the 2026-08-18 row (TS `copyPhoto`), so photos from albums shared with us can now be kept. The re-encryption is what `add_photos_to_album` already did — name re-encrypted to the target key and signed by the photos membership address, name/content hashes recomputed under the target hash key, node passphrase **rewrapped** via `rewrap_message_to` so the original `NodePassphraseSignature` survives — so it was lifted into a shared `photo_target_payload` (C# `PhotosCrypto.EncryptPhotoForTarget` + `PhotoTransferPayloadBuilder`) that both paths build on. Retry semantics mirror upstream exactly: a photo the server reports an incomplete group for is re-queued **once** with the related photos it named, and only its second answer becomes an outcome; a photo already parented to the timeline root fails its own outcome. Also ported: `ProtonPhotosClient::enumerate_events` (`c8d03244`'s only non-interop hunk — a passthrough to the Drive client's, which we already had). diverged deliberately: upstream returns `IAsyncEnumerable<NodeActionResult>`; ours collects `Vec<(NodeUid, Result<()>)>` like `move_nodes` and the other batch ops — and, because of the retry queue, outcome order follows completion, not input, which the API docs state. not ported: `29e58378` **per-file upload performance metrics** (`UploadPerformanceEvent`, size/route/block-count classification, active-vs-paused attempt timing on `UploadController`) — it is telemetry plumbing for C#'s interop metric callback; our `proton-sdk::telemetry` has its own `TelemetryEvent`/`OpTimer` shape and we have no pause/resume upload controller, and nothing about the upload itself changes. structural, not ported: `0537d906` (C# merges its batch loaders and `DtoToMetadataConverter` into one `NodeProvider`; the only behavior-adjacent bit is dropping the cache's claim/acquisition dance, which our single-flight already covers). Live validation pending: the whole path needs a photos-volume account, and the cross-volume half needs a second account sharing an album with it. |
| 2026-09-15 | `c8d03244` → `ce000edd` | `cdc0f260`, `4b1129aa`, `63a7fc1e`, `5cbbb0ad`, `e33712b9` | ported: **trash time field name** (`cdc0f260` — C# dropped a `[JsonPropertyName("Trashed")]` that never matched the API's `TrashTime`; ours had copied the same wrong name, so `LinkDto` now reads `TrashTime` and keeps `Trashed` as a separate legacy field rather than a serde alias, which would fail a link carrying both). **direct role + membership detail** (`4b1129aa`): `Node::direct_role` — `Admin` on our own main or photos volume (`is_own_volume`, C# `VolumeOperations.IsOwnVolumeAsync`), otherwise the membership's permissions or `Inherited`; `ShareMembership` gains `invite_time`, `inviter_email` and `inviter_verification`, the last from checking the inviter's `drive.share-member.inviter` signature over our share-passphrase key packet against the claimed inviter's keys (C# `NodeCrypto.VerifyMembershipInviter`; new `proton_sdk::crypto::verify_detached_with_context`, which requires the `context@proton.ch` notation to match and accepts armored or base64-binary signatures). Non-fatal like every other verification. Kept optional (`Option<MemberRole>` / `Option<VerificationStatus>`, `skip_serializing_if` on the membership additions) so persisted `Node` blobs keep deserializing and read back as "unknown" rather than as a wrong role; C# models `SharedBy` as `Result<Author, SignatureVerificationError>` — ours is the claimed email plus the status. **share id** (`63a7fc1e`): `Node::share_id`, the `Sharing` block's `ShareID` (C# `DeprecatedShareId`, internal there; public here as the JS SDK has it). **batched move with rename, streamed** (`5cbbb0ad` + `e33712b9` — upstream finally replaced its per-node loop with the `move-multiple` endpoint we already used): new `move_nodes_streaming(Vec<NodeMoveItem>, NodeUid)` yielding `(NodeUid, Result<()>)` per node as each batch lands, with `NodeMoveItem::target_name` for rename-on-move; batches of 150 that never mix anonymously signed nodes with signed ones; an anonymously signed node now gets its passphrase signed by the mover plus `SignatureEmail` — **also fixed in `move_node`**, which never did this (C# `MoveSingleAsync` always had); a link rejected `InvalidRequirements` (2000) is retried once with the server's `NameHash` provided parent and decrypted name are unchanged, else fails out of sync (C# `PrepareItemWithRemoteNameDigestAsync`). `move_nodes` keeps its signature and input-order contract and now collects the stream. diverged deliberately: `NodeMoveItem` carries no `CurrentParentUid`/`CurrentName` — we read both off the link rather than trust the caller, so upstream's retry mostly exists for a case we only hit through a stale cached `OriginalHash`; per-code exception types (`NodeWithSameNameExistsException`, `FolderCapacityExceededException`, `FolderNestingTooDeepException`, `CannotMoveFavoritePhotoFromShareException`, `NodeOutOfSyncException`, `ParentFolderNotFoundException`) are not ported — the per-link `ProtonError::Api` keeps the `ResponseCode` to match on; `RenameNodeAsync` removal not mirrored (`rename_node` stays, `proton-drive-linux` uses it). no-op: `e3d602b9` (C# `HashingReadStream`/`HashingWriteStream` hashed the whole buffer instead of `offset..count`; our upload hashes `buf.truncate(n)`-ed blocks and has no stream wrapper). noise: `df87c3aa` (AEAD feature-flag removal, chore). **Downstream:** adding `Node::{direct_role, share_id}` and `ShareMembership::{invite_time, inviter_email, inviter_verification}` breaks struct literals in `proton-drive-linux` tests (`pdfs-fuse/src/state.rs`) at the next SDK bump. Live validation pending for all of it — membership verification and anonymous-node moves need a second account.|
+2
View File
@@ -166,6 +166,8 @@ mod tests {
is_shared_publicly: false, is_shared_publicly: false,
signature_email: None, signature_email: None,
membership: None, membership: None,
direct_role: None,
share_id: None,
photo: None, photo: None,
album: None, album: None,
verification: crate::node::NodeVerification::default(), verification: crate::node::NodeVerification::default(),
+466 -186
View File
@@ -20,10 +20,11 @@ use proton_sdk::api::ResponseCode;
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::{
ContentKey, DEFAULT_BIT_LENGTH, VerificationKeyRing, VerificationStatus, accept_invitation, ContentKey, DEFAULT_BIT_LENGTH, SHARING_INVITER_CONTEXT, VerificationKeyRing,
build_standard_share_material, build_volume_creation_material, decrypt_armored_with_keys, VerificationStatus, accept_invitation, build_standard_share_material,
derive_key_passphrase, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, build_volume_creation_material, decrypt_armored_with_keys, derive_key_passphrase,
generate_node_key, generate_node_key_aead, generate_verifier, verify_detached, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, generate_node_key,
generate_node_key_aead, generate_verifier, verify_detached, verify_detached_with_context,
}; };
use proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_sdk::error::{ProtonApiError, ProtonError, Result};
use proton_sdk::http::ApiHttpClient; use proton_sdk::http::ApiHttpClient;
@@ -76,8 +77,8 @@ use crate::dtos::{
}; };
use crate::events::{DriveEvent, DriveEventScopeId}; use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{ use crate::node::{
AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState,
ThumbnailType, Thumbnail, ThumbnailType,
}; };
use crate::photos::{ use crate::photos::{
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
@@ -2637,7 +2638,14 @@ impl ProtonDriveClient {
let (_address_id, email, signing_key) = self.membership_address().await?; let (_address_id, email, signing_key) = self.membership_address().await?;
let parts = self let parts = self
.build_move_parts(uid, &link, &dest_parent_key, &dest_hash_key, &signing_key) .build_move_parts(
uid,
&link,
None,
&dest_parent_key,
&dest_hash_key,
&signing_key,
)
.await?; .await?;
let request = MoveLinkRequest { let request = MoveLinkRequest {
@@ -2645,12 +2653,14 @@ impl ProtonDriveClient {
passphrase: parts.passphrase, passphrase: parts.passphrase,
// The rewrap preserves the plaintext, so the existing detached // The rewrap preserves the plaintext, so the existing detached
// signature stays valid and is not re-sent (C# `MoveSingleAsync` // signature stays valid and is not re-sent (C# `MoveSingleAsync`
// sends `PassphraseSignature = null` for non-anonymous nodes; only // sends `PassphraseSignature = null` for signed nodes). Passing the
// an anonymous move re-signs). Passing the link's own value back is // link's own value back is wrong: the API returns it as an empty
// wrong: the API returns it as an empty string for these nodes, and // string for these nodes, and a serialized empty
// a serialized empty `NodePassphraseSignature` is rejected 400 // `NodePassphraseSignature` is rejected 400 "should not be empty".
// "should not be empty" — the batch path already hardcodes `None`. // An anonymously signed node has no signer to keep, so the mover
passphrase_signature: None, // signs its passphrase and names itself as `SignatureEmail`.
signature_email: parts.anonymous_signature.as_ref().map(|_| email.clone()),
passphrase_signature: parts.anonymous_signature,
name: parts.encrypted_name, name: parts.encrypted_name,
name_signature_email: email, name_signature_email: email,
name_hash: parts.name_hash, name_hash: parts.name_hash,
@@ -2668,22 +2678,18 @@ impl ProtonDriveClient {
Ok(()) Ok(())
} }
/// Move several nodes under a single destination parent in one batched /// Move several nodes under a single destination parent, keeping their
/// request. Mirrors C# `ProtonDriveClient.MoveNodesAsync` / /// names. Collects [`move_nodes_streaming`](Self::move_nodes_streaming) —
/// `NodeOperations.MoveMultipleAsync` (`PUT volumes/{vid}/links/move-multiple`, /// see it for batching, retries and anonymously signed nodes. Mirrors C#
/// note: no `v2/` prefix — upstream still runs a per-node loop here and /// `ProtonDriveClient.MoveNodesAsync` (`PUT volumes/{vid}/links/move-multiple`,
/// FIXMEs the batch endpoint we already use). Same-volume only — cross-volume /// no `v2/` prefix). Same-volume only. Live validation pending.
/// is rejected, matching the C# batch path, which also throws for differing
/// volumes. Each node's passphrase is rewrapped to the destination key and its
/// name re-encrypted + signed, exactly as the single [`move_node`]. Batched in
/// chunks of [`MAX_BATCH_COUNT`]. Live validation pending.
/// ///
/// One outcome per input uid, in input order, like C#'s /// One outcome per input uid, in input order: a node that fails —
/// `IReadOnlyDictionary<NodeUid, Result<Exception>>`: a node that fails —
/// cross-volume, unknown link, crypto, or a per-link error code in the batch /// cross-volume, unknown link, crypto, or a per-link error code in the batch
/// envelope — does not stop the others. The outer `Err` is reserved for /// envelope — does not stop the others, and a uid listed twice is moved once
/// failures that make the whole call impossible (destination key, hash key, /// with both positions reporting that outcome. The outer `Err` is reserved
/// signing address, link lookup). /// for failures that make the whole call impossible (destination key, hash
/// key, signing address, link lookup).
pub async fn move_nodes( pub async fn move_nodes(
&self, &self,
uids: &[NodeUid], uids: &[NodeUid],
@@ -2691,168 +2697,39 @@ impl ProtonDriveClient {
) -> Result<Vec<(NodeUid, Result<()>)>> { ) -> Result<Vec<(NodeUid, Result<()>)>> {
let mut timer = self.telemetry.start("move_nodes"); let mut timer = self.telemetry.start("move_nodes");
timer.attr("node_count", uids.len()); timer.attr("node_count", uids.len());
if uids.is_empty() {
timer.success();
return Ok(Vec::new());
}
// Cross-volume nodes never reach the request — C# `MoveSingleAsync` throws let mut seen: HashSet<&NodeUid> = HashSet::with_capacity(uids.len());
// for them, and here that throw is one node's outcome, not the call's. let items: Vec<NodeMoveItem> = uids
let cross_volume: Vec<bool> = uids
.iter() .iter()
.map(|uid| uid.volume_id != new_parent.volume_id) .filter(|uid| seen.insert(*uid))
.collect(); .map(|uid| NodeMoveItem {
uid: uid.clone(),
// Each remaining link is moved once even if the caller listed it twice; target_name: None,
// every position sharing it reports the same outcome below.
let mut seen: HashSet<LinkId> = HashSet::with_capacity(uids.len());
let mut targets: Vec<LinkId> = Vec::with_capacity(uids.len());
for (uid, &skip) in uids.iter().zip(&cross_volume) {
if !skip && seen.insert(uid.link_id.clone()) {
targets.push(uid.link_id.clone());
}
}
let mut link_outcomes: HashMap<LinkId, Result<()>> = HashMap::with_capacity(targets.len());
if !targets.is_empty() {
let dest_parent_key = self.folder_node_key(new_parent).await?;
let dest_hash_key = self.parent_hash_key(new_parent, &dest_parent_key).await?;
let (_address_id, email, signing_key) = self.membership_address().await?;
// Resolve every node's link details once (all share the destination
// volume), keyed by link id so each chunk can look its node up.
let mut links: HashMap<LinkId, LinkDto> = HashMap::with_capacity(targets.len());
for chunk in targets.chunks(MAX_BATCH_COUNT) {
let details = self.get_link_details(&new_parent.volume_id, chunk).await?;
for detail in details.links {
links.insert(detail.link.id.clone(), detail.link);
}
}
for chunk in targets.chunks(MAX_BATCH_COUNT) {
let mut items = Vec::with_capacity(chunk.len());
// The links this request actually carries, so the per-link
// responses can be routed back.
let mut sent: Vec<LinkId> = Vec::with_capacity(chunk.len());
for link_id in chunk {
let uid = NodeUid::new(new_parent.volume_id.clone(), link_id.clone());
let Some(link) = links.get(link_id) else {
link_outcomes.insert(
link_id.clone(),
Err(ProtonError::invalid_operation(format!(
"node {uid} not found"
))),
);
continue;
};
let parts = match self
.build_move_parts(
&uid,
link,
&dest_parent_key,
&dest_hash_key,
&signing_key,
)
.await
{
Ok(parts) => parts,
Err(e) => {
link_outcomes.insert(link_id.clone(), Err(e));
continue;
}
};
items.push(MoveMultipleLinksItem {
link_id: link_id.clone(),
name: parts.encrypted_name,
passphrase: parts.passphrase,
name_hash: parts.name_hash,
original_hash: parts.original_hash,
// The rewrap preserves the plaintext; the existing detached
// passphrase signature stays valid, so none is re-sent (C#
// omits it for non-anonymous nodes).
passphrase_signature: None,
});
sent.push(link_id.clone());
}
if items.is_empty() {
continue;
}
let request = MoveMultipleLinksRequest {
parent_link_id: new_parent.link_id.clone(),
links: items,
name_signature_email: email.clone(),
signature_email: None,
};
let path = format!("volumes/{}/links/move-multiple", new_parent.volume_id);
let client = self.clone();
let result = run_context_share_mutation(
self.context_share_gate.clone(),
self.cache.clone(),
async move { client.http.put(&path, &request).await },
)
.await;
let response: AggregateLinksResponse = match result {
Ok(response) => response,
Err(e) => {
// The chunk failed as a whole; charge it to every node it
// carried and keep going with the remaining chunks, so one
// bad chunk cannot mask the rest (C#'s per-node loop never
// stops early either).
let message = e.to_string();
for link_id in sent {
link_outcomes.insert(
link_id,
Err(ProtonError::invalid_operation(message.clone())),
);
}
continue;
}
};
let mut per_link: HashMap<LinkId, Result<()>> =
aggregate_outcomes(response).into_iter().collect();
for link_id in sent {
let outcome = per_link.remove(&link_id).unwrap_or_else(|| {
Err(ProtonError::invalid_operation(format!(
"move returned no response for link {link_id}"
)))
});
link_outcomes.insert(link_id, outcome);
}
}
}
// A repeated uid cannot take the owned `Result` twice, so keep each
// link's error text to rebuild the outcome for its later positions.
let repeats: HashMap<LinkId, Option<String>> = link_outcomes
.iter()
.map(|(link_id, outcome)| {
(
link_id.clone(),
outcome.as_ref().err().map(|e| e.to_string()),
)
}) })
.collect(); .collect();
let mut outcomes: HashMap<NodeUid, Result<()>> = self
.move_nodes_streaming(items, new_parent.clone())
.try_collect()
.await?;
// A repeated uid cannot take the owned `Result` twice, so keep each
// node's error text to rebuild the outcome for its later positions.
let mut repeats: HashMap<&NodeUid, Option<String>> = HashMap::new();
let results: Vec<(NodeUid, Result<()>)> = uids let results: Vec<(NodeUid, Result<()>)> = uids
.iter() .iter()
.zip(&cross_volume) .map(|uid| {
.map(|(uid, &skip)| { let outcome = match outcomes.remove(uid) {
let outcome = if skip { Some(outcome) => {
Err(ProtonError::invalid_operation( repeats.insert(uid, outcome.as_ref().err().map(|e| e.to_string()));
"cross-volume move is not supported", outcome
)) }
} else if let Some(outcome) = link_outcomes.remove(&uid.link_id) { None => match repeats.get(uid) {
outcome
} else {
match repeats.get(&uid.link_id) {
Some(None) => Ok(()), Some(None) => Ok(()),
Some(Some(message)) => Err(ProtonError::invalid_operation(message.clone())), Some(Some(message)) => Err(ProtonError::invalid_operation(message.clone())),
None => Err(ProtonError::invalid_operation(format!( None => Err(ProtonError::invalid_operation(format!(
"move produced no outcome for node {uid}" "move produced no outcome for node {uid}"
))), ))),
} },
}; };
(uid.clone(), outcome) (uid.clone(), outcome)
}) })
@@ -2865,15 +2742,273 @@ impl ProtonDriveClient {
Ok(results) Ok(results)
} }
/// Build the per-node move crypto shared by [`move_node`] and [`move_nodes`]: /// Move nodes under one parent — renaming each on the way when its
/// resolve the source parent, rewrap the passphrase to `dest_parent_key`, /// [`NodeMoveItem::target_name`] says so — yielding every node's outcome as
/// re-encrypt + sign the name to the destination, and compute the new name /// the batch carrying it comes back. Mirrors C#
/// hash (under `dest_hash_key`) and the original hash (under the source /// `NodeMoveOperation.MoveMultipleAsync` (upstream `5cbbb0ad`, streamed since
/// parent's hash key). Mirrors the body of C# `MoveSingleAsync`. /// `e33712b9`). Live validation pending.
///
/// - Batches hold at most [`MAX_BATCH_COUNT`] links and never mix
/// anonymously signed nodes with signed ones: an anonymous node's
/// passphrase is signed afresh by the mover, and the request's
/// `SignatureEmail` then names the mover for every link in it.
/// - A link rejected with `InvalidRequirements` (2000) — the `OriginalHash`
/// sent no longer matches — is retried once with the name hash the server
/// holds, provided the node still sits under the parent, with the name, the
/// move was prepared against. Otherwise it fails as out of sync.
/// - Per-node failures are outcomes: cross-volume, an invalid target name, a
/// link that does not exist, crypto, a per-link error code (kept as
/// [`ProtonError::Api`], so `AlreadyExists`, `TooManyChildren` and the rest
/// stay matchable). A batch whose request fails outright charges that to
/// every node it carried, and the stream carries on.
/// - The outer `Err` ends the stream: the move could not start at all
/// (destination key, hash key, signing address, link lookup).
///
/// Outcome order follows completion, not input. A uid listed twice is moved
/// once; its second entry gets an error of its own.
pub fn move_nodes_streaming<'a>(
&'a self,
items: Vec<NodeMoveItem>,
new_parent: NodeUid,
) -> impl futures::Stream<Item = Result<(NodeUid, Result<()>)>> + 'a {
stream::once(self.prepare_moves(items, new_parent)).flat_map(move |prepared| match prepared
{
Ok(run) => self.submit_moves(run).left_stream(),
Err(e) => stream::iter([Err(e)]).right_stream(),
})
}
/// Validate, look up and encrypt every move up front; what fails here is
/// already an outcome.
async fn prepare_moves(&self, items: Vec<NodeMoveItem>, parent: NodeUid) -> Result<MoveRun> {
let mut outcomes = VecDeque::new();
let mut seen: HashSet<LinkId> = HashSet::with_capacity(items.len());
let mut targets = Vec::with_capacity(items.len());
for item in items {
let rejected = if item.uid.volume_id != parent.volume_id {
Some(ProtonError::invalid_operation(
"cross-volume move is not supported",
))
} else if let Some(Err(e)) = item.target_name.as_deref().map(validate_node_name) {
Some(e)
} else if !seen.insert(item.uid.link_id.clone()) {
Some(ProtonError::invalid_operation(format!(
"node {} is listed more than once in one move",
item.uid
)))
} else {
None
};
match rejected {
Some(e) => outcomes.push_back((item.uid, Err(e))),
None => targets.push(item),
}
}
let mut run = MoveRun {
parent,
email: String::new(),
queue: VecDeque::with_capacity(targets.len()),
outcomes,
};
if targets.is_empty() {
return Ok(run);
}
let dest_parent_key = self.folder_node_key(&run.parent).await?;
let dest_hash_key = self.parent_hash_key(&run.parent, &dest_parent_key).await?;
let (_address_id, email, signing_key) = self.membership_address().await?;
run.email = email;
let link_ids: Vec<LinkId> = targets
.iter()
.map(|item| item.uid.link_id.clone())
.collect();
let mut links: HashMap<LinkId, LinkDto> = HashMap::with_capacity(link_ids.len());
for chunk in link_ids.chunks(MAX_BATCH_COUNT) {
let details = self.get_link_details(&run.parent.volume_id, chunk).await?;
for detail in details.links {
links.insert(detail.link.id.clone(), detail.link);
}
}
for item in targets {
let Some(link) = links.get(&item.uid.link_id) else {
let error = ProtonError::invalid_operation(format!("node {} not found", item.uid));
run.outcomes.push_back((item.uid, Err(error)));
continue;
};
let parts = match self
.build_move_parts(
&item.uid,
link,
item.target_name.as_deref(),
&dest_parent_key,
&dest_hash_key,
&signing_key,
)
.await
{
Ok(parts) => parts,
Err(e) => {
run.outcomes.push_back((item.uid, Err(e)));
continue;
}
};
run.queue.push_back(PreparedMove {
item: MoveMultipleLinksItem {
link_id: item.uid.link_id.clone(),
name: parts.encrypted_name,
passphrase: parts.passphrase,
name_hash: parts.name_hash,
original_hash: parts.original_hash,
passphrase_signature: parts.anonymous_signature.clone(),
},
anonymous: parts.anonymous_signature.is_some(),
source_parent: link.parent_id.clone(),
current_name: parts.current_name,
retried: false,
uid: item.uid,
});
}
Ok(run)
}
/// Send the prepared moves batch by batch, reporting as each lands.
fn submit_moves<'a>(
&'a self,
run: MoveRun,
) -> impl futures::Stream<Item = Result<(NodeUid, Result<()>)>> + 'a {
stream::unfold(run, move |mut run| async move {
loop {
if let Some(outcome) = run.outcomes.pop_front() {
return Some((Ok(outcome), run));
}
let batch = take_move_batch(&mut run.queue);
if batch.is_empty() {
return None;
}
self.submit_move_batch(&mut run, batch).await;
}
})
}
async fn submit_move_batch(&self, run: &mut MoveRun, batch: Vec<PreparedMove>) {
let anonymous = batch.first().is_some_and(|prepared| prepared.anonymous);
let request = MoveMultipleLinksRequest {
parent_link_id: run.parent.link_id.clone(),
links: batch.iter().map(|prepared| prepared.item.clone()).collect(),
name_signature_email: run.email.clone(),
signature_email: anonymous.then(|| run.email.clone()),
};
let path = format!("volumes/{}/links/move-multiple", run.parent.volume_id);
let client = self.clone();
let result: Result<AggregateLinksResponse> = run_context_share_mutation(
self.context_share_gate.clone(),
self.cache.clone(),
async move { client.http.put(&path, &request).await },
)
.await;
let response = match result {
Ok(response) => response,
Err(e) => {
// The batch failed as a whole; charge it to every node it
// carried and keep going, so one bad batch cannot mask the rest.
// An API error keeps its code on every node: a one-link batch
// rejected as a whole is how a name collision usually arrives,
// and callers branch on that code.
for prepared in batch {
let error = match &e {
ProtonError::Api(api) => ProtonError::Api(api.clone()),
other => ProtonError::invalid_operation(other.to_string()),
};
run.outcomes.push_back((prepared.uid, Err(error)));
}
return;
}
};
let mut per_link: HashMap<LinkId, Result<()>> =
aggregate_outcomes(response).into_iter().collect();
for mut prepared in batch {
match per_link.remove(&prepared.uid.link_id) {
Some(Err(ProtonError::Api(error)))
if error.code == ResponseCode::InvalidRequirements && !prepared.retried =>
{
match self.remote_original_hash(&prepared).await {
Ok(hash) => {
prepared.item.original_hash = hash;
prepared.retried = true;
run.queue.push_back(prepared);
}
Err(e) => run.outcomes.push_back((prepared.uid, Err(e))),
}
}
Some(outcome) => run.outcomes.push_back((prepared.uid, outcome)),
None => {
let error = ProtonError::invalid_operation(format!(
"move returned no response for node {}",
prepared.uid
));
run.outcomes.push_back((prepared.uid, Err(error)));
}
}
}
}
/// The name hash the server holds for a node whose move it rejected as out
/// of date (C# `PrepareItemWithRemoteNameDigestAsync`). Only a node still
/// under the parent, and with the name, the move was prepared against
/// qualifies: anything else changed underneath the move, and retrying would
/// undo that change.
async fn remote_original_hash(&self, prepared: &PreparedMove) -> Result<String> {
let out_of_sync = || {
ProtonError::invalid_operation(format!(
"node {} changed while it was being moved",
prepared.uid
))
};
let details = self
.get_link_details(
&prepared.uid.volume_id,
std::slice::from_ref(&prepared.uid.link_id),
)
.await?;
let link = details
.links
.into_iter()
.next()
.ok_or_else(|| {
ProtonError::invalid_operation(format!("node {} not found", prepared.uid))
})?
.link;
let (Some(parent_id), Some(hash)) = (&link.parent_id, &link.name_hash) else {
return Err(out_of_sync());
};
if Some(parent_id) != prepared.source_parent.as_ref() || hash.is_empty() {
return Err(out_of_sync());
}
let parent_uid = NodeUid::new(prepared.uid.volume_id.clone(), parent_id.clone());
let parent_key = self.folder_node_key(&parent_uid).await?;
if parent_key.decrypt_armored_message(&link.name)? != prepared.current_name {
return Err(out_of_sync());
}
Ok(hash.clone())
}
/// Build the per-node move crypto shared by [`move_node`] and
/// [`move_nodes_streaming`]: resolve the source parent, rewrap the passphrase
/// to `dest_parent_key`, encrypt + sign the name (`target_name`, else the
/// current one) to the destination, and compute the new name hash (under
/// `dest_hash_key`) and the original hash (under the source parent's hash
/// key). An anonymously signed node also gets its passphrase signed by
/// `signing_key`, as C# does from `PassphraseForAnonymousMove`. Mirrors the
/// body of C# `MoveSingleAsync` / `NodeMoveOperation.TryPrepareItemAsync`.
async fn build_move_parts( async fn build_move_parts(
&self, &self,
uid: &NodeUid, uid: &NodeUid,
link: &LinkDto, link: &LinkDto,
target_name: Option<&str>,
dest_parent_key: &PrivateKey, dest_parent_key: &PrivateKey,
dest_hash_key: &[u8], dest_hash_key: &[u8],
signing_key: &PrivateKey, signing_key: &PrivateKey,
@@ -2888,20 +3023,30 @@ impl ProtonDriveClient {
let source_hash_key = self let source_hash_key = self
.parent_hash_key(&source_parent_uid, &source_parent_key) .parent_hash_key(&source_parent_uid, &source_parent_key)
.await?; .await?;
let name = source_parent_key.decrypt_armored_message(&link.name)?; let current_name = source_parent_key.decrypt_armored_message(&link.name)?;
let original_hash = self let original_hash = self
.original_name_hash(uid, link, &source_parent_key, &source_hash_key) .original_name_hash(uid, link, &source_parent_key, &source_hash_key)
.await?; .await?;
let passphrase = source_parent_key.rewrap_message_to(&link.passphrase, dest_parent_key)?; let passphrase = source_parent_key.rewrap_message_to(&link.passphrase, dest_parent_key)?;
let encrypted_name = dest_parent_key.encrypt_and_sign(signing_key, &name, true, false)?; let anonymous_signature = if link.signature_email.as_deref().is_none_or(str::is_empty) {
let name_hash = hex::encode(hmac_sha256(dest_hash_key, &name)); let plaintext = source_parent_key.decrypt_armored_message(&link.passphrase)?;
Some(signing_key.sign_detached(&plaintext)?)
} else {
None
};
let name = target_name.map_or(current_name.as_slice(), str::as_bytes);
let encrypted_name = dest_parent_key.encrypt_and_sign(signing_key, name, true, false)?;
let name_hash = hex::encode(hmac_sha256(dest_hash_key, name));
Ok(MoveParts { Ok(MoveParts {
passphrase, passphrase,
encrypted_name, encrypted_name,
name_hash, name_hash,
original_hash, original_hash,
anonymous_signature,
current_name,
}) })
} }
@@ -7246,6 +7391,19 @@ impl ProtonDriveClient {
} }
}; };
// C# `DtoToMetadataConverter.BuildMembershipInfoAsync`: everything on
// our own volumes is ours to administer; elsewhere the role is the
// membership's, and a node below the shared root inherits.
let direct_role = if self.is_own_volume(volume_id).await {
MemberRole::Admin
} else {
MemberRole::from_permissions(details.membership.as_ref().map(|m| m.permissions))
};
let membership = match &details.membership {
Some(dto) => Some(self.verified_membership(dto).await),
None => None,
};
// C# `DtoToMetadataConverter`: the `Sharing` block's presence marks the // C# `DtoToMetadataConverter`: the `Sharing` block's presence marks the
// node as shared; a `ShareURLID` inside it marks it as publicly shared. // node as shared; a `ShareURLID` inside it marks it as publicly shared.
let node = Node { let node = Node {
@@ -7264,7 +7422,12 @@ impl ProtonDriveClient {
signature_email: link.signature_email.clone(), signature_email: link.signature_email.clone(),
// Present only when the node is shared *with* us; it is what says // Present only when the node is shared *with* us; it is what says
// whether we may write to it. // whether we may write to it.
membership: details.membership.as_ref().map(share_membership_from_dto), membership,
direct_role: Some(direct_role),
share_id: details
.sharing
.as_ref()
.map(|sharing| sharing.share_id.clone()),
photo, photo,
album, album,
verification, verification,
@@ -7334,6 +7497,63 @@ impl ProtonDriveClient {
let current_name = parent_key.decrypt_armored_message(&link.name)?; let current_name = parent_key.decrypt_armored_message(&link.name)?;
Ok(hex::encode(hmac_sha256(parent_hash_key, &current_name))) Ok(hex::encode(hmac_sha256(parent_hash_key, &current_name)))
} }
/// Whether `volume_id` is our main or photos volume (C#
/// `VolumeOperations.IsOwnVolumeAsync`). Both ids are cached after the first
/// lookup; a lookup that fails counts as "not ours", which only costs a node
/// its [`MemberRole::Admin`] and never fails the read.
async fn is_own_volume(&self, volume_id: &VolumeId) -> bool {
if self
.main_volume_id()
.await
.is_ok_and(|main| &main == volume_id)
{
return true;
}
if !self.ensure_photos().await.unwrap_or(false) {
return false;
}
self.cache
.lock()
.await
.photos_root
.as_ref()
.is_some_and(|root| &root.volume_id == volume_id)
}
/// Our membership in a share, with the inviter's signature over our share
/// passphrase key packet checked against the claimed inviter's keys (C#
/// `BuildMembershipInfoAsync` + `NodeCrypto.VerifyMembershipInviter`). The
/// check is metadata: the outcome is recorded, never raised.
async fn verified_membership(&self, dto: &ShareMembershipSummaryDto) -> ShareMembership {
let mut membership = share_membership_from_dto(dto);
let status = match (
&dto.member_share_passphrase_key_packet,
&dto.inviter_share_passphrase_key_packet_signature,
) {
(Some(packet), Some(signature)) => match BASE64.decode(packet) {
Ok(packet) => {
let email = dto.inviter_email.as_deref().unwrap_or("");
let keys = if email.is_empty() {
Vec::new()
} else {
self.account.public_keys(email).await
};
verify_detached_with_context(
signature,
&packet,
&VerificationKeyRing::from_public_keys(&keys),
SHARING_INVITER_CONTEXT,
)
}
Err(_) => VerificationStatus::Failed,
},
// The invitation carries nothing to verify.
_ => VerificationStatus::NotSigned,
};
membership.inviter_verification = Some(status);
membership
}
} }
/// Verify a downloaded revision's content manifest signature, returning the /// Verify a downloaded revision's content manifest signature, returning the
@@ -7641,6 +7861,55 @@ struct MoveParts {
encrypted_name: String, encrypted_name: String,
name_hash: String, name_hash: String,
original_hash: String, original_hash: String,
/// A fresh signature over the passphrase, for an anonymously signed node.
anonymous_signature: Option<String>,
/// The name the node carries now, decrypted.
current_name: Vec<u8>,
}
/// A batch move in progress: what is left to send, and what is ready to report.
struct MoveRun {
parent: NodeUid,
/// The membership address names — and anonymous nodes' passphrases — are
/// signed as.
email: String,
queue: VecDeque<PreparedMove>,
outcomes: VecDeque<(NodeUid, Result<()>)>,
}
/// One node's move, encrypted and ready for a batch (C# `PreparedNodeMoveItem`).
struct PreparedMove {
uid: NodeUid,
item: MoveMultipleLinksItem,
/// Where the move was prepared from, and the name it carried then: what a
/// retry checks the server's copy against.
source_parent: Option<LinkId>,
current_name: Vec<u8>,
anonymous: bool,
/// Whether `item.original_hash` already came from the server, which makes a
/// second `InvalidRequirements` final.
retried: bool,
}
/// The next request's worth of moves, taken from the front of `queue`: at most
/// [`MAX_BATCH_COUNT`], all signed the same way, the rest left in order (C#
/// `MoveBatch.TryAddToCurrentBatch`). An anonymous node's request names the
/// mover as `SignatureEmail`, which a signed node's must not.
fn take_move_batch(queue: &mut VecDeque<PreparedMove>) -> Vec<PreparedMove> {
let Some(anonymous) = queue.front().map(|prepared| prepared.anonymous) else {
return Vec::new();
};
let mut batch = Vec::new();
let mut rest = VecDeque::with_capacity(queue.len());
for prepared in queue.drain(..) {
if batch.len() < MAX_BATCH_COUNT && prepared.anonymous == anonymous {
batch.push(prepared);
} else {
rest.push_back(prepared);
}
}
*queue = rest;
batch
} }
/// Split a `1001 MultipleResponses` batch envelope into one outcome per link, /// Split a `1001 MultipleResponses` batch envelope into one outcome per link,
@@ -7971,6 +8240,10 @@ fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership
share_id: dto.share_id.clone(), share_id: dto.share_id.clone(),
membership_id: dto.membership_id.clone(), membership_id: dto.membership_id.clone(),
permissions: dto.permissions, permissions: dto.permissions,
invite_time: dto.invite_time,
inviter_email: dto.inviter_email.clone(),
// Needs the inviter's keys; `verified_membership` fills it in.
inviter_verification: None,
} }
} }
@@ -8214,12 +8487,19 @@ mod tests {
share_id: ShareId::new("share-1"), share_id: ShareId::new("share-1"),
membership_id: ShareMembershipId::new("membership-1"), membership_id: ShareMembershipId::new("membership-1"),
permissions: 38, permissions: 38,
invite_time: Some(1_700_000_000),
inviter_email: Some("owner@proton.me".into()),
member_share_passphrase_key_packet: None,
inviter_share_passphrase_key_packet_signature: None,
}; };
let membership = share_membership_from_dto(&dto); let membership = share_membership_from_dto(&dto);
assert_eq!(membership.share_id, dto.share_id); assert_eq!(membership.share_id, dto.share_id);
assert_eq!(membership.membership_id, dto.membership_id); assert_eq!(membership.membership_id, dto.membership_id);
assert_eq!(membership.permissions, 38); assert_eq!(membership.permissions, 38);
assert_eq!(membership.invite_time, Some(1_700_000_000));
assert_eq!(membership.inviter_email.as_deref(), Some("owner@proton.me"));
assert_eq!(membership.inviter_verification, None);
assert_eq!(membership.role(), MemberRole::Viewer); assert_eq!(membership.role(), MemberRole::Viewer);
assert_eq!(membership.role_exact(), None); assert_eq!(membership.role_exact(), None);
} }
+28 -3
View File
@@ -179,6 +179,18 @@ pub struct ShareMembershipSummaryDto {
pub membership_id: ShareMembershipId, pub membership_id: ShareMembershipId,
#[serde(rename = "Permissions", default)] #[serde(rename = "Permissions", default)]
pub permissions: i32, pub permissions: i32,
/// When the node was shared with us, epoch seconds.
#[serde(rename = "InviteTime", default)]
pub invite_time: Option<i64>,
#[serde(rename = "InviterEmail", default)]
pub inviter_email: Option<String>,
/// base64 PKESK: the share passphrase session key encrypted to us.
#[serde(rename = "MemberSharePassphraseKeyPacket", default)]
pub member_share_passphrase_key_packet: Option<String>,
/// The inviter's signature over that key packet, made under the
/// `drive.share-member.inviter` context.
#[serde(rename = "InviterSharePassphraseKeyPacketSignature", default)]
pub inviter_share_passphrase_key_packet_signature: Option<String>,
} }
impl LinkDetailsDto { impl LinkDetailsDto {
@@ -566,8 +578,15 @@ pub struct LinkDto {
pub creation_time: i64, pub creation_time: i64,
#[serde(rename = "ModifyTime")] #[serde(rename = "ModifyTime")]
pub modification_time: i64, pub modification_time: i64,
#[serde(rename = "Trashed")] /// The API's name for the trash time (upstream `cdc0f260` dropped C#'s
/// `[JsonPropertyName("Trashed")]`, which never matched it).
#[serde(rename = "TrashTime", default)]
pub trash_time: Option<i64>, pub trash_time: Option<i64>,
/// The name this DTO used to read. Kept as its own field rather than a serde
/// `alias`, which would fail the whole link with "duplicate field" on a
/// response that carries both.
#[serde(rename = "Trashed", default)]
pub trashed_legacy: Option<i64>,
#[serde(rename = "Name")] #[serde(rename = "Name")]
pub name: String, pub name: String,
/// Lowercase-hex HMAC-SHA256 name hash under the parent's hash key (C# /// Lowercase-hex HMAC-SHA256 name hash under the parent's hash key (C#
@@ -593,7 +612,9 @@ impl LinkDto {
} }
pub fn is_trashed(&self) -> bool { pub fn is_trashed(&self) -> bool {
self.state == LinkState::Trashed as i32 || self.trash_time.is_some() self.state == LinkState::Trashed as i32
|| self.trash_time.is_some()
|| self.trashed_legacy.is_some()
} }
} }
@@ -1041,6 +1062,10 @@ pub struct MoveLinkRequest {
pub name_hash: String, pub name_hash: String,
#[serde(rename = "OriginalHash")] #[serde(rename = "OriginalHash")]
pub original_hash: String, pub original_hash: String,
/// Set only when moving an anonymously signed node, whose passphrase the
/// mover signs afresh (C# `MoveSingleLinkRequest.SignatureEmailAddress`).
#[serde(rename = "SignatureEmail", skip_serializing_if = "Option::is_none")]
pub signature_email: Option<String>,
} }
/// `PUT volumes/{vid}/links/move-multiple` — batch move of several nodes under a /// `PUT volumes/{vid}/links/move-multiple` — batch move of several nodes under a
@@ -1062,7 +1087,7 @@ pub struct MoveMultipleLinksRequest {
/// One entry of a [`MoveMultipleLinksRequest`]. Mirrors C# `MoveMultipleLinksItem`: /// One entry of a [`MoveMultipleLinksRequest`]. Mirrors C# `MoveMultipleLinksItem`:
/// per-node rewrapped passphrase + re-encrypted/signed name + new/original name /// per-node rewrapped passphrase + re-encrypted/signed name + new/original name
/// hashes under the destination/source hash keys. /// hashes under the destination/source hash keys.
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct MoveMultipleLinksItem { pub struct MoveMultipleLinksItem {
#[serde(rename = "LinkID")] #[serde(rename = "LinkID")]
pub link_id: LinkId, pub link_id: LinkId,
+2 -2
View File
@@ -56,8 +56,8 @@ pub use event_manager::{
}; };
pub use events::{DriveEvent, DriveEventScopeId}; pub use events::{DriveEvent, DriveEventScopeId};
pub use node::{ pub use node::{
AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState,
ThumbnailType, Thumbnail, ThumbnailType,
}; };
pub use photos::{ pub use photos::{
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
+33 -2
View File
@@ -1,11 +1,24 @@
//! Public, decrypted node model returned to callers. //! Public, decrypted node model returned to callers.
use proton_sdk::crypto::VerificationStatus; use proton_sdk::crypto::VerificationStatus;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::{NodeUid, ShareId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::photos::PhotoTag; use crate::photos::PhotoTag;
use crate::sharing::ShareMembership; use crate::sharing::{MemberRole, ShareMembership};
/// One node for [`move_nodes_streaming`](crate::ProtonDriveClient::move_nodes_streaming):
/// which node, and the name it should carry afterwards.
///
/// Mirrors C# `NodeMoveItem` minus its `CurrentParentUid` / `CurrentName`: this
/// client reads both off the link it is about to move rather than trusting a
/// caller's possibly stale copy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeMoveItem {
pub uid: NodeUid,
/// The name after the move; `None` keeps the current one.
pub target_name: Option<String>,
}
/// A decrypted Drive node (folder or file). /// A decrypted Drive node (folder or file).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,6 +51,19 @@ pub struct Node {
/// one written before this field existed must keep deserializing. /// one written before this field existed must keep deserializing.
#[serde(default)] #[serde(default)]
pub membership: Option<ShareMembership>, pub membership: Option<ShareMembership>,
/// Our role on this node itself (C# `Node.DirectRole`): [`MemberRole::Admin`]
/// on our own main or photos volume, the membership's role on a node shared
/// directly with us, [`MemberRole::Inherited`] on anything below it.
///
/// `None` on a node persisted before this field existed, and on nodes built
/// without an account (a public-link visitor has no role to report).
#[serde(default)]
pub direct_role: Option<MemberRole>,
/// The share this node is the root of, when it is shared — the `Sharing`
/// block's `ShareID` (JS `deprecatedShareId`, carried by C# since `63a7fc1e`).
/// Kept for callers still addressing sharing by share rather than by node.
#[serde(default)]
pub share_id: Option<ShareId>,
/// Photo-only metadata, present on a photos-volume file node. C# layers this /// Photo-only metadata, present on a photos-volume file node. C# layers this
/// as `PhotoNode : FileNode`; here it is an optional block so a `Node` /// as `PhotoNode : FileNode`; here it is an optional block so a `Node`
/// persisted before photos existed still deserializes. /// persisted before photos existed still deserializes.
@@ -353,7 +379,12 @@ mod tests {
share_id: "share-1".into(), share_id: "share-1".into(),
membership_id: "member-1".into(), membership_id: "member-1".into(),
permissions: 6, permissions: 6,
invite_time: Some(1_700_000_000),
inviter_email: Some("owner@proton.me".into()),
inviter_verification: Some(VerificationStatus::Ok),
}), }),
direct_role: Some(MemberRole::Editor),
share_id: Some("share-1".into()),
photo: None, photo: None,
album: None, album: None,
verification: NodeVerification::default(), verification: NodeVerification::default(),
@@ -1189,6 +1189,8 @@ impl ProtonDrivePublicLinkClient {
// A public-link visitor has no account membership — access comes // A public-link visitor has no account membership — access comes
// from the link's own share password, not from a share member row. // from the link's own share password, not from a share member row.
membership: None, membership: None,
direct_role: None,
share_id: None,
// A public link points at a Drive folder or file; the photos volume // A public link points at a Drive folder or file; the photos volume
// is not reachable this way, so there is no photo/album metadata. // is not reachable this way, so there is no photo/album metadata.
photo: None, photo: None,
+23 -1
View File
@@ -5,12 +5,13 @@
//! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`). //! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`).
//! Reading shared-with-me nodes and leaving them lives on the client already. //! Reading shared-with-me nodes and leaving them lives on the client already.
use proton_sdk::crypto::VerificationStatus;
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId}; use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A sharing permission role. The wire form is a permissions bitmask /// A sharing permission role. The wire form is a permissions bitmask
/// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations. /// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MemberRole { pub enum MemberRole {
/// Read-only (`4`). /// Read-only (`4`).
Viewer, Viewer,
@@ -87,6 +88,21 @@ pub struct ShareMembership {
/// The raw permissions bitmask, kept so an unrecognised value can still be /// The raw permissions bitmask, kept so an unrecognised value can still be
/// logged or round-tripped rather than being flattened into a role. /// logged or round-tripped rather than being flattened into a role.
pub permissions: i32, pub permissions: i32,
/// When the node was shared with us, epoch seconds (C# `Membership.InviteTime`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invite_time: Option<i64>,
/// Who *claims* to have shared it. Trust it only as far as
/// [`inviter_verification`](Self::inviter_verification) says — C# hands this
/// back as a `SignatureVerificationError` carrying the claimed author when
/// the invitation signature does not check out (possibly forged).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inviter_email: Option<String>,
/// The inviter's `drive.share-member.inviter` signature over our share
/// passphrase key packet, checked against the claimed inviter's keys
/// (C# `NodeCrypto.VerifyMembershipInviter`). Non-fatal metadata; `None` on
/// a membership persisted before the check existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inviter_verification: Option<VerificationStatus>,
} }
impl ShareMembership { impl ShareMembership {
@@ -282,6 +298,9 @@ mod tests {
share_id: "s1".into(), share_id: "s1".into(),
membership_id: "m1".into(), membership_id: "m1".into(),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}; };
let editor = membership(6); let editor = membership(6);
@@ -306,6 +325,9 @@ mod tests {
share_id: "share-1".into(), share_id: "share-1".into(),
membership_id: "member-1".into(), membership_id: "member-1".into(),
permissions: 22, permissions: 22,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}; };
let json = serde_json::to_value(&membership).unwrap(); let json = serde_json::to_value(&membership).unwrap();
assert_eq!( assert_eq!(
+4 -1
View File
@@ -26,7 +26,10 @@ pub use errors::CryptoError;
pub use keys::{PrivateKey, decrypt_armored_with_keys}; pub use keys::{PrivateKey, decrypt_armored_with_keys};
pub use messages::decrypt_armored_with_password; pub use messages::decrypt_armored_with_password;
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier}; pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier};
pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached}; pub use verify::{
PublicKey, VerificationKeyRing, VerificationStatus, verify_detached,
verify_detached_with_context,
};
/// Result alias for crypto operations. /// Result alias for crypto operations.
pub type CryptoResult<T> = std::result::Result<T, CryptoError>; pub type CryptoResult<T> = std::result::Result<T, CryptoError>;
+1 -1
View File
@@ -324,7 +324,7 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String,
/// separation (`context@proton.ch`, critical). Proton signs sharing invitations, /// separation (`context@proton.ch`, critical). Proton signs sharing invitations,
/// members and external invitations under distinct contexts so a signature made /// members and external invitations under distinct contexts so a signature made
/// for one purpose cannot be replayed as another. /// for one purpose cannot be replayed as another.
const SIGNATURE_CONTEXT_NOTATION: &[u8] = b"context@proton.ch"; pub(crate) const SIGNATURE_CONTEXT_NOTATION: &[u8] = b"context@proton.ch";
/// Like [`sign_detached`] but stamps a GopenPGP signature *context* — a critical /// Like [`sign_detached`] but stamps a GopenPGP signature *context* — a critical
/// `context@proton.ch` notation — into the hashed subpackets, and returns the /// `context@proton.ch` notation — into the hashed subpackets, and returns the
+103
View File
@@ -8,6 +8,8 @@
//! `NodeCrypto` records the `PgpVerificationStatus` of each decrypt rather than //! `NodeCrypto` records the `PgpVerificationStatus` of each decrypt rather than
//! throwing. //! throwing.
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use pgp::composed::{ use pgp::composed::{
Deserializable, DetachedSignature, Message, SignedPublicKey, SignedSecretKey, Deserializable, DetachedSignature, Message, SignedPublicKey, SignedSecretKey,
VerificationResult, VerificationResult,
@@ -15,6 +17,7 @@ use pgp::composed::{
use pgp::types::{Password, VerifyingKey}; use pgp::types::{Password, VerifyingKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::encrypt::SIGNATURE_CONTEXT_NOTATION;
use super::errors::CryptoError; use super::errors::CryptoError;
/// Outcome of an authorship/signature check. /// Outcome of an authorship/signature check.
@@ -137,6 +140,48 @@ pub fn verify_detached(
VerificationStatus::Failed VerificationStatus::Failed
} }
/// Verify a detached signature made under a GopenPGP signature *context* (the
/// `context@proton.ch` notation) — the counterpart of the sharing signers in
/// `encrypt.rs`, and of C# `PgpVerificationContext`.
///
/// The signature may be armored or base64 of the binary packet: the sharing
/// endpoints are not consistent about which they return. A signature that
/// verifies but was made under another context — or none — is `Failed`, since
/// accepting it would let a signature made for one purpose stand in for another.
pub fn verify_detached_with_context(
signature: &str,
data: &[u8],
ring: &VerificationKeyRing,
context: &str,
) -> VerificationStatus {
if ring.is_empty() {
return VerificationStatus::NoVerifier;
}
let parsed = DetachedSignature::from_string(signature)
.map(|(signature, _headers)| signature)
.ok()
.or_else(|| {
let bytes = BASE64.decode(signature.trim()).ok()?;
DetachedSignature::from_bytes(&bytes[..]).ok()
});
let Some(signature) = parsed else {
return VerificationStatus::Failed;
};
let in_context = signature.signature.notations().iter().any(|notation| {
notation.name.as_ref() == SIGNATURE_CONTEXT_NOTATION
&& notation.value.as_ref() == context.as_bytes()
});
if !in_context {
return VerificationStatus::Failed;
}
for key in &ring.keys {
if signature.verify(key, data).is_ok() {
return VerificationStatus::Ok;
}
}
VerificationStatus::Failed
}
/// Decrypt an armored PGP message and verify its inline signature (if any) /// Decrypt an armored PGP message and verify its inline signature (if any)
/// against `ring`. Always returns the plaintext; the status is metadata. /// against `ring`. Always returns the plaintext; the status is metadata.
pub(crate) fn decrypt_and_verify( pub(crate) fn decrypt_and_verify(
@@ -243,6 +288,64 @@ mod tests {
); );
} }
#[test]
fn context_signature_verifies_only_under_its_own_context() {
use crate::crypto::encrypt::sign_detached_binary_with_context;
use crate::crypto::{SHARING_INVITER_CONTEXT, SHARING_MEMBER_CONTEXT};
let signer = generate_node_key().expect("generate signer key");
let data = b"share passphrase key packet";
let binary = sign_detached_binary_with_context(&signer.key, data, SHARING_INVITER_CONTEXT)
.expect("sign");
let base64 = BASE64.encode(&binary);
let ring = VerificationKeyRing::from_private(&signer.key);
assert_eq!(
verify_detached_with_context(&base64, data, &ring, SHARING_INVITER_CONTEXT),
VerificationStatus::Ok
);
// The right key over the right bytes, made for a different purpose.
assert_eq!(
verify_detached_with_context(&base64, data, &ring, SHARING_MEMBER_CONTEXT),
VerificationStatus::Failed
);
assert_eq!(
verify_detached_with_context(&base64, b"other packet", &ring, SHARING_INVITER_CONTEXT),
VerificationStatus::Failed
);
assert_eq!(
verify_detached_with_context(
&base64,
data,
&VerificationKeyRing::empty(),
SHARING_INVITER_CONTEXT
),
VerificationStatus::NoVerifier
);
}
#[test]
fn a_signature_without_a_context_is_not_a_context_signature() {
let signer = generate_node_key().expect("generate signer key");
let data = b"share passphrase key packet";
let armored = signer.key.sign_detached(data).expect("sign");
let ring = VerificationKeyRing::from_private(&signer.key);
assert_eq!(
verify_detached(&armored, data, &ring),
VerificationStatus::Ok
);
assert_eq!(
verify_detached_with_context(
&armored,
data,
&ring,
crate::crypto::SHARING_INVITER_CONTEXT
),
VerificationStatus::Failed
);
}
#[test] #[test]
fn inline_signed_message_round_trips_with_status() { fn inline_signed_message_round_trips_with_status() {
let signer = generate_node_key().expect("generate signer key"); let signer = generate_node_key().expect("generate signer key");