diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index 0c035a4..a75e781 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the mid-2026 projects reorg; the script watches both) -- **Pinned**: `c8d03244938a6b4d107c755df8904d7d971ed1c2` -- **Date**: 2026-08-27 +- **Pinned**: `ce000edd053768bf8fd2da04f3ef4fa68ba9e71e` +- **Date**: 2026-09-15 > **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. @@ -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-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`; 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` / `Option`, `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` — 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, 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.| diff --git a/crates/proton-drive-rs/src/cache.rs b/crates/proton-drive-rs/src/cache.rs index 1f16ecf..8cc66af 100644 --- a/crates/proton-drive-rs/src/cache.rs +++ b/crates/proton-drive-rs/src/cache.rs @@ -166,6 +166,8 @@ mod tests { is_shared_publicly: false, signature_email: None, membership: None, + direct_role: None, + share_id: None, photo: None, album: None, verification: crate::node::NodeVerification::default(), diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 0f0fc01..2c48dca 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -20,10 +20,11 @@ use proton_sdk::api::ResponseCode; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::{ - ContentKey, DEFAULT_BIT_LENGTH, VerificationKeyRing, VerificationStatus, accept_invitation, - build_standard_share_material, build_volume_creation_material, decrypt_armored_with_keys, - derive_key_passphrase, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, - generate_node_key, generate_node_key_aead, generate_verifier, verify_detached, + ContentKey, DEFAULT_BIT_LENGTH, SHARING_INVITER_CONTEXT, VerificationKeyRing, + VerificationStatus, accept_invitation, build_standard_share_material, + build_volume_creation_material, decrypt_armored_with_keys, derive_key_passphrase, + 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::http::ApiHttpClient; @@ -76,8 +77,8 @@ use crate::dtos::{ }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ - AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, - ThumbnailType, + AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState, + Thumbnail, ThumbnailType, }; use crate::photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, @@ -2637,7 +2638,14 @@ impl ProtonDriveClient { let (_address_id, email, signing_key) = self.membership_address().await?; 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?; let request = MoveLinkRequest { @@ -2645,12 +2653,14 @@ impl ProtonDriveClient { passphrase: parts.passphrase, // The rewrap preserves the plaintext, so the existing detached // signature stays valid and is not re-sent (C# `MoveSingleAsync` - // sends `PassphraseSignature = null` for non-anonymous nodes; only - // an anonymous move re-signs). Passing the link's own value back is - // wrong: the API returns it as an empty string for these nodes, and - // a serialized empty `NodePassphraseSignature` is rejected 400 - // "should not be empty" — the batch path already hardcodes `None`. - passphrase_signature: None, + // sends `PassphraseSignature = null` for signed nodes). Passing the + // link's own value back is wrong: the API returns it as an empty + // string for these nodes, and a serialized empty + // `NodePassphraseSignature` is rejected 400 "should not be empty". + // An anonymously signed node has no signer to keep, so the mover + // 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_signature_email: email, name_hash: parts.name_hash, @@ -2668,22 +2678,18 @@ impl ProtonDriveClient { Ok(()) } - /// Move several nodes under a single destination parent in one batched - /// request. Mirrors C# `ProtonDriveClient.MoveNodesAsync` / - /// `NodeOperations.MoveMultipleAsync` (`PUT volumes/{vid}/links/move-multiple`, - /// note: no `v2/` prefix — upstream still runs a per-node loop here and - /// FIXMEs the batch endpoint we already use). Same-volume only — cross-volume - /// 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. + /// Move several nodes under a single destination parent, keeping their + /// names. Collects [`move_nodes_streaming`](Self::move_nodes_streaming) — + /// see it for batching, retries and anonymously signed nodes. Mirrors C# + /// `ProtonDriveClient.MoveNodesAsync` (`PUT volumes/{vid}/links/move-multiple`, + /// no `v2/` prefix). Same-volume only. Live validation pending. /// - /// One outcome per input uid, in input order, like C#'s - /// `IReadOnlyDictionary>`: a node that fails — + /// One outcome per input uid, in input order: a node that fails — /// 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 - /// failures that make the whole call impossible (destination key, hash key, - /// signing address, link lookup). + /// envelope — does not stop the others, and a uid listed twice is moved once + /// with both positions reporting that outcome. The outer `Err` is reserved + /// for failures that make the whole call impossible (destination key, hash + /// key, signing address, link lookup). pub async fn move_nodes( &self, uids: &[NodeUid], @@ -2691,168 +2697,39 @@ impl ProtonDriveClient { ) -> Result)>> { let mut timer = self.telemetry.start("move_nodes"); 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 - // for them, and here that throw is one node's outcome, not the call's. - let cross_volume: Vec = uids + let mut seen: HashSet<&NodeUid> = HashSet::with_capacity(uids.len()); + let items: Vec = uids .iter() - .map(|uid| uid.volume_id != new_parent.volume_id) - .collect(); - - // Each remaining link is moved once even if the caller listed it twice; - // every position sharing it reports the same outcome below. - let mut seen: HashSet = HashSet::with_capacity(uids.len()); - let mut targets: Vec = 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> = 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 = 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 = 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> = - 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> = link_outcomes - .iter() - .map(|(link_id, outcome)| { - ( - link_id.clone(), - outcome.as_ref().err().map(|e| e.to_string()), - ) + .filter(|uid| seen.insert(*uid)) + .map(|uid| NodeMoveItem { + uid: uid.clone(), + target_name: None, }) .collect(); + let mut outcomes: HashMap> = 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> = HashMap::new(); let results: Vec<(NodeUid, Result<()>)> = uids .iter() - .zip(&cross_volume) - .map(|(uid, &skip)| { - let outcome = if skip { - Err(ProtonError::invalid_operation( - "cross-volume move is not supported", - )) - } else if let Some(outcome) = link_outcomes.remove(&uid.link_id) { - outcome - } else { - match repeats.get(&uid.link_id) { + .map(|uid| { + let outcome = match outcomes.remove(uid) { + Some(outcome) => { + repeats.insert(uid, outcome.as_ref().err().map(|e| e.to_string())); + outcome + } + None => match repeats.get(uid) { Some(None) => Ok(()), Some(Some(message)) => Err(ProtonError::invalid_operation(message.clone())), None => Err(ProtonError::invalid_operation(format!( "move produced no outcome for node {uid}" ))), - } + }, }; (uid.clone(), outcome) }) @@ -2865,15 +2742,273 @@ impl ProtonDriveClient { Ok(results) } - /// Build the per-node move crypto shared by [`move_node`] and [`move_nodes`]: - /// resolve the source parent, rewrap the passphrase to `dest_parent_key`, - /// re-encrypt + sign the name to the destination, and compute the new name - /// hash (under `dest_hash_key`) and the original hash (under the source - /// parent's hash key). Mirrors the body of C# `MoveSingleAsync`. + /// Move nodes under one parent — renaming each on the way when its + /// [`NodeMoveItem::target_name`] says so — yielding every node's outcome as + /// the batch carrying it comes back. Mirrors C# + /// `NodeMoveOperation.MoveMultipleAsync` (upstream `5cbbb0ad`, streamed since + /// `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, + new_parent: NodeUid, + ) -> impl futures::Stream)>> + '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, parent: NodeUid) -> Result { + let mut outcomes = VecDeque::new(); + let mut seen: HashSet = 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 = targets + .iter() + .map(|item| item.uid.link_id.clone()) + .collect(); + let mut links: HashMap = 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)>> + '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) { + 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 = 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> = + 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 { + 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( &self, uid: &NodeUid, link: &LinkDto, + target_name: Option<&str>, dest_parent_key: &PrivateKey, dest_hash_key: &[u8], signing_key: &PrivateKey, @@ -2888,20 +3023,30 @@ impl ProtonDriveClient { let source_hash_key = self .parent_hash_key(&source_parent_uid, &source_parent_key) .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 .original_name_hash(uid, link, &source_parent_key, &source_hash_key) .await?; 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 name_hash = hex::encode(hmac_sha256(dest_hash_key, &name)); + let anonymous_signature = if link.signature_email.as_deref().is_none_or(str::is_empty) { + 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 { passphrase, encrypted_name, name_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 // node as shared; a `ShareURLID` inside it marks it as publicly shared. let node = Node { @@ -7264,7 +7422,12 @@ impl ProtonDriveClient { signature_email: link.signature_email.clone(), // Present only when the node is shared *with* us; it is what says // 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, album, verification, @@ -7334,6 +7497,63 @@ impl ProtonDriveClient { let current_name = parent_key.decrypt_armored_message(&link.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 @@ -7641,6 +7861,55 @@ struct MoveParts { encrypted_name: String, name_hash: String, original_hash: String, + /// A fresh signature over the passphrase, for an anonymously signed node. + anonymous_signature: Option, + /// The name the node carries now, decrypted. + current_name: Vec, +} + +/// 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, + 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, + current_name: Vec, + 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) -> Vec { + 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, @@ -7971,6 +8240,10 @@ fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership share_id: dto.share_id.clone(), membership_id: dto.membership_id.clone(), 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"), membership_id: ShareMembershipId::new("membership-1"), 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); assert_eq!(membership.share_id, dto.share_id); assert_eq!(membership.membership_id, dto.membership_id); 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_exact(), None); } diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index c0a43b2..63de54c 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -179,6 +179,18 @@ pub struct ShareMembershipSummaryDto { pub membership_id: ShareMembershipId, #[serde(rename = "Permissions", default)] pub permissions: i32, + /// When the node was shared with us, epoch seconds. + #[serde(rename = "InviteTime", default)] + pub invite_time: Option, + #[serde(rename = "InviterEmail", default)] + pub inviter_email: Option, + /// base64 PKESK: the share passphrase session key encrypted to us. + #[serde(rename = "MemberSharePassphraseKeyPacket", default)] + pub member_share_passphrase_key_packet: Option, + /// 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, } impl LinkDetailsDto { @@ -566,8 +578,15 @@ pub struct LinkDto { pub creation_time: i64, #[serde(rename = "ModifyTime")] 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, + /// 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, #[serde(rename = "Name")] pub name: String, /// 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 { - 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, #[serde(rename = "OriginalHash")] 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, } /// `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`: /// per-node rewrapped passphrase + re-encrypted/signed name + new/original name /// hashes under the destination/source hash keys. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct MoveMultipleLinksItem { #[serde(rename = "LinkID")] pub link_id: LinkId, diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index 10646ce..19d62c7 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -56,8 +56,8 @@ pub use event_manager::{ }; pub use events::{DriveEvent, DriveEventScopeId}; pub use node::{ - AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, - ThumbnailType, + AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState, + Thumbnail, ThumbnailType, }; pub use photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index e73c9d2..97ffe59 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -1,11 +1,24 @@ //! Public, decrypted node model returned to callers. use proton_sdk::crypto::VerificationStatus; -use proton_sdk::ids::NodeUid; +use proton_sdk::ids::{NodeUid, ShareId}; use serde::{Deserialize, Serialize}; 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, +} /// A decrypted Drive node (folder or file). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -38,6 +51,19 @@ pub struct Node { /// one written before this field existed must keep deserializing. #[serde(default)] pub membership: Option, + /// 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, + /// 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, /// 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` /// persisted before photos existed still deserializes. @@ -353,7 +379,12 @@ mod tests { share_id: "share-1".into(), membership_id: "member-1".into(), 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, album: None, verification: NodeVerification::default(), diff --git a/crates/proton-drive-rs/src/public_link.rs b/crates/proton-drive-rs/src/public_link.rs index b995641..246086c 100644 --- a/crates/proton-drive-rs/src/public_link.rs +++ b/crates/proton-drive-rs/src/public_link.rs @@ -1189,6 +1189,8 @@ impl ProtonDrivePublicLinkClient { // A public-link visitor has no account membership — access comes // from the link's own share password, not from a share member row. membership: None, + direct_role: None, + share_id: None, // 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. photo: None, diff --git a/crates/proton-drive-rs/src/sharing.rs b/crates/proton-drive-rs/src/sharing.rs index 08178fb..65cbb7a 100644 --- a/crates/proton-drive-rs/src/sharing.rs +++ b/crates/proton-drive-rs/src/sharing.rs @@ -5,12 +5,13 @@ //! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`). //! 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 serde::{Deserialize, Serialize}; /// A sharing permission role. The wire form is a permissions bitmask /// (`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 { /// Read-only (`4`). Viewer, @@ -87,6 +88,21 @@ pub struct ShareMembership { /// The raw permissions bitmask, kept so an unrecognised value can still be /// logged or round-tripped rather than being flattened into a role. 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, + /// 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, + /// 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, } impl ShareMembership { @@ -282,6 +298,9 @@ mod tests { share_id: "s1".into(), membership_id: "m1".into(), permissions, + invite_time: None, + inviter_email: None, + inviter_verification: None, }; let editor = membership(6); @@ -306,6 +325,9 @@ mod tests { share_id: "share-1".into(), membership_id: "member-1".into(), permissions: 22, + invite_time: None, + inviter_email: None, + inviter_verification: None, }; let json = serde_json::to_value(&membership).unwrap(); assert_eq!( diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index 376a276..1322b3f 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -26,7 +26,10 @@ pub use errors::CryptoError; pub use keys::{PrivateKey, decrypt_armored_with_keys}; pub use messages::decrypt_armored_with_password; 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. pub type CryptoResult = std::result::Result; diff --git a/crates/proton-sdk/src/crypto/encrypt.rs b/crates/proton-sdk/src/crypto/encrypt.rs index 635c613..745908c 100644 --- a/crates/proton-sdk/src/crypto/encrypt.rs +++ b/crates/proton-sdk/src/crypto/encrypt.rs @@ -324,7 +324,7 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result 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) /// against `ring`. Always returns the plaintext; the status is metadata. 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] fn inline_signed_message_round_trips_with_status() { let signer = generate_node_key().expect("generate signer key");