mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
81844cb9da
commit
d72065418b
11 files changed
+667
-198
No files matched your search
+3
-2
@@ -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.|
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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, ¤t_name)))
|
Ok(hex::encode(hmac_sha256(parent_hash_key, ¤t_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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user