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

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

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

No files matched your search

+4 -1
View File
@@ -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>;
+1 -1
View File
@@ -324,7 +324,7 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result<String,
/// separation (`context@proton.ch`, critical). Proton signs sharing invitations,
/// 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
+103
View File
@@ -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");