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
@@ -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(),
|
||||
|
||||
@@ -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<NodeUid, Result<Exception>>`: 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<Vec<(NodeUid, 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<bool> = uids
|
||||
let mut seen: HashSet<&NodeUid> = HashSet::with_capacity(uids.len());
|
||||
let items: Vec<NodeMoveItem> = 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<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()),
|
||||
)
|
||||
.filter(|uid| seen.insert(*uid))
|
||||
.map(|uid| NodeMoveItem {
|
||||
uid: uid.clone(),
|
||||
target_name: None,
|
||||
})
|
||||
.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
|
||||
.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<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(
|
||||
&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<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,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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<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")]
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// `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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<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
|
||||
/// 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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<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,
|
||||
/// members and external invitations under distinct contexts so a signature made
|
||||
/// 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
|
||||
/// `context@proton.ch` notation — into the hashed subpackets, and returns the
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! `NodeCrypto` records the `PgpVerificationStatus` of each decrypt rather than
|
||||
//! throwing.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use pgp::composed::{
|
||||
Deserializable, DetachedSignature, Message, SignedPublicKey, SignedSecretKey,
|
||||
VerificationResult,
|
||||
@@ -15,6 +17,7 @@ use pgp::composed::{
|
||||
use pgp::types::{Password, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::encrypt::SIGNATURE_CONTEXT_NOTATION;
|
||||
use super::errors::CryptoError;
|
||||
|
||||
/// Outcome of an authorship/signature check.
|
||||
@@ -137,6 +140,48 @@ pub fn verify_detached(
|
||||
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)
|
||||
/// 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");
|
||||
|
||||
Reference in new issue
Block a user