From 64f977edb3fcdbc106af6acc8dd307bdce73668e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 15 Jul 2026 19:34:40 +0200 Subject: [PATCH] feature (devices+sharing) + upgraded rand and aeds --- Cargo.lock | 196 +++- Cargo.toml | 13 +- crates/proton-drive-rs/Cargo.toml | 3 +- crates/proton-drive-rs/src/client.rs | 914 ++++++++++++++++++- crates/proton-drive-rs/src/dtos.rs | 414 +++++++++ crates/proton-drive-rs/src/lib.rs | 5 + crates/proton-drive-rs/src/sharing.rs | 184 ++++ crates/proton-drive-rs/tests/live_devices.rs | 136 +++ crates/proton-drive-rs/tests/live_sharing.rs | 252 +++++ crates/proton-sdk/Cargo.toml | 1 + crates/proton-sdk/src/account.rs | 37 + crates/proton-sdk/src/account/dtos.rs | 6 + crates/proton-sdk/src/cache.rs | 6 +- crates/proton-sdk/src/crypto.rs | 8 +- crates/proton-sdk/src/crypto/content.rs | 299 +++++- crates/proton-sdk/src/crypto/encrypt.rs | 402 +++++++- crates/proton-sdk/src/crypto/srp.rs | 41 +- crates/proton-sdk/src/crypto/verify.rs | 2 +- crates/proton-sdk/src/http.rs | 4 +- 19 files changed, 2830 insertions(+), 93 deletions(-) create mode 100644 crates/proton-drive-rs/src/sharing.rs create mode 100644 crates/proton-drive-rs/tests/live_devices.rs create mode 100644 crates/proton-drive-rs/tests/live_sharing.rs diff --git a/Cargo.lock b/Cargo.lock index bcca6f5..ac8c747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,10 +15,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "bytes", - "crypto-common 0.1.7", + "crypto-common 0.1.6", "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -30,17 +40,42 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", - "ghash", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", "subtle", ] @@ -50,7 +85,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" dependencies = [ - "aes", + "aes 0.8.4", ] [[package]] @@ -269,9 +304,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" @@ -340,6 +375,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -359,7 +405,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.1.6", "inout 0.1.4", ] @@ -369,6 +415,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", ] @@ -446,6 +493,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -484,9 +537,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "rand_core 0.6.4", @@ -499,7 +552,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -511,6 +566,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -690,7 +754,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common 0.1.7", + "crypto-common 0.1.6", "subtle", ] @@ -745,10 +809,10 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", "cmac", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -966,9 +1030,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1011,6 +1075,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1020,7 +1085,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.2", ] [[package]] @@ -1505,9 +1579,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1557,9 +1631,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", @@ -1639,9 +1713,9 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -1733,9 +1807,9 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" dependencies = [ - "aead", - "aes", - "aes-gcm", + "aead 0.5.2", + "aes 0.8.4", + "aes-gcm 0.10.3", "aes-kw", "argon2", "base64", @@ -1833,7 +1907,18 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b20f20e954175de5f463f67781b35583397d916b1d148738923711b2ad16bee8" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -1874,12 +1959,13 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "base64", "bytes", "futures", + "getrandom 0.4.3", "hex", "hmac 0.13.0", "pgp", @@ -1897,22 +1983,23 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.1.5" +version = "0.1.6" dependencies = [ - "aes-gcm", + "aes-gcm 0.11.0", "async-trait", "base64", "bcrypt", "bytes", "chrono", "futures", - "getrandom 0.2.17", + "getrandom 0.4.3", "hex", "hkdf 0.13.0", "hmac 0.13.0", "num-bigint", "num-traits", "pgp", + "rand 0.10.2", "rand 0.8.6", "reqwest", "serde", @@ -1954,7 +2041,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand 0.9.5", "ring", "rustc-hash", "rustls", @@ -2020,14 +2107,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2066,6 +2164,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "replace_with" version = "0.1.8" @@ -2259,9 +2363,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -2841,10 +2945,20 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3363,18 +3477,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 16fbc2e..3ccbc00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/proton-sdk", "crates/proton-drive-rs"] [workspace.package] -version = "0.1.5" +version = "0.1.6" edition = "2024" license = "MIT" repository = "https://github.com/narrrl/proton-sdk-rs" @@ -28,16 +28,19 @@ hex = "0.4" hmac = "0.13" sha1 = "0.11" sha2 = "0.11" -aes-gcm = "0.10" +aes-gcm = "0.11" hkdf = "0.13" bcrypt = "0.19" pgp = "0.20" # Match the chrono line rPGP pulls in; used to stamp detached-signature subpackets. chrono = "0.4" tracing = "0.1" -num-bigint = "0.4" +num-bigint = "0.5" num-traits = "0.2" -getrandom = "0.2" +getrandom = "0.4" +# General-purpose RNG (HTTP retry jitter, etc.). +rand = "0.10" # rPGP (pgp 0.20) still depends on rand 0.8 and passes its CryptoRng/Rng traits # across its public API, so the RNG we hand to keygen/encryption must be rand 0.8. -rand = "0.8" +# Kept under an alias alongside rand 0.10; crypto/ uses `rand_08`, the rest `rand`. +rand_08 = { package = "rand", version = "0.8" } diff --git a/crates/proton-drive-rs/Cargo.toml b/crates/proton-drive-rs/Cargo.toml index 0669b9a..4a152ae 100644 --- a/crates/proton-drive-rs/Cargo.toml +++ b/crates/proton-drive-rs/Cargo.toml @@ -14,7 +14,7 @@ readme = "../../README.md" documentation = "https://docs.rs/proton-drive-rs" [dependencies] -proton-sdk = { path = "../proton-sdk", version = "0.1.5" } +proton-sdk = { path = "../proton-sdk", version = "0.1.6" } tokio.workspace = true reqwest.workspace = true serde.workspace = true @@ -25,6 +25,7 @@ async-trait.workspace = true futures.workspace = true bytes.workspace = true base64.workspace = true +getrandom.workspace = true hex.workspace = true hmac.workspace = true sha1.workspace = true diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index e21c24c..2954d39 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -13,8 +13,10 @@ use proton_sdk::account::{AccountClient, KeySalt}; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::{ - ContentKey, VerificationKeyRing, VerificationStatus, build_volume_creation_material, - generate_node_hash_key, generate_node_key, generate_node_key_aead, verify_detached, + 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, }; use proton_sdk::error::{ProtonError, Result}; use proton_sdk::http::ApiHttpClient; @@ -35,25 +37,35 @@ use crate::crypto::{ }; use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ - AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, - BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier, - CommonExtendedAttributes, DeviceCreationDeviceDto, DeviceCreationLinkDto, - DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, - DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, FileContentDigests, - FileCreationRequest, FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, - FolderCreationResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, - LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest, MoveMultipleLinksItem, - MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, + AcceptInvitationRequest, AggregateLinksResponse, BlockCreationRequest, BlockDto, + BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockVerificationInputResponse, + BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes, + CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, + CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, + DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, + DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, + ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, + FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse, + InvitationDetailsResponse, InvitationsListResponse, InviteEmailDetailsDto, + InviteExternalUserRequest, InviteExternalUserResponse, InviteProtonUserInvitationDto, + InviteProtonUserRequest, InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, + LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, + MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, - RevisionResponse, RevisionUpdateRequest, ShareResponse, ShareTargetType, SharedWithMeResponse, + RevisionResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, + ShareMembersResponse, ShareResponse, ShareTargetType, ShareUrlsResponse, SharedWithMeResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, - TimelinePhotoListResponse, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, - VolumeTrashResponse, + TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, + VolumeEventListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem}; +use crate::sharing::{ + Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole, + PublicLink, ShareInvitation, ShareMember, +}; /// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`). const DEFAULT_BLOCK_SIZE: usize = 1 << 22; @@ -209,6 +221,13 @@ impl ProtonDriveClient { &self.account } + /// Total account storage usage (`MaxSpace`/`UsedSpace`), account-wide across + /// all Proton products. Convenience passthrough to + /// [`AccountClient::quota`](proton_sdk::account::AccountClient::quota). + pub async fn quota(&self) -> Result { + self.account.quota().await + } + /// Attach a telemetry observer to receive a /// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each /// instrumented operation (transfers, navigation, mutations) plus a @@ -1625,6 +1644,777 @@ impl ProtonDriveClient { Ok(()) } + /// Share a node with one or more Proton users by email. + /// + /// Creates a standard share on the node if it is not shared yet, then invites + /// each user at the given [`MemberRole`]. Ported from the TypeScript SDK's + /// `shareNode` (the C# public SDK has no share-creation surface): the share + /// key is generated and bound to the node + owning address, and each invite + /// carries the share session key encrypted to the invitee's public key plus a + /// context-bound inviter signature over it. + /// + /// `email_message` is an optional note included in the invitation emails. + /// Returns the freshly created invitations. Invitees already invited or + /// already members are skipped (this does not update their role). + pub async fn share_node( + &self, + uid: &NodeUid, + invitees: &[(String, MemberRole)], + email_message: Option<&str>, + ) -> Result> { + let mut timer = self.telemetry.start("share_node"); + + let (share_id, share_session_key, inviter_email, inviter_key) = + self.ensure_node_share(uid).await?; + + // Skip anyone who is already invited or already a member. + let existing_invites = self.list_share_invitations_inner(&share_id).await?; + let existing_members = self.list_share_members_inner(&share_id).await?; + let already: std::collections::HashSet = existing_invites + .iter() + .map(|i| i.invitee_email.to_lowercase()) + .chain(existing_members.iter().map(|m| m.email.to_lowercase())) + .collect(); + + let mut created = Vec::new(); + for (email, role) in invitees { + if already.contains(&email.to_lowercase()) { + tracing::info!(%email, "skipping already-invited/member for share"); + continue; + } + let permissions = role.to_permissions().ok_or_else(|| { + ProtonError::invalid_operation("cannot invite a user with the inherited role") + })?; + + let invitee_keys = self.account.public_keys(email).await; + let invitee_pubkey = invitee_keys.first().ok_or_else(|| { + ProtonError::invalid_operation(format!("no public key for invitee {email}")) + })?; + + let (key_packet, key_packet_signature) = + encrypt_invitation(&share_session_key, &inviter_key, invitee_pubkey)?; + + let request = InviteProtonUserRequest { + invitation: InviteProtonUserInvitationDto { + inviter_email: inviter_email.clone(), + invitee_email: email.clone(), + permissions, + key_packet, + key_packet_signature, + external_invitation_id: None, + }, + email_details: InviteEmailDetailsDto { + message: email_message.map(str::to_string), + item_name: None, + }, + }; + + let path = format!("v2/shares/{share_id}/invitations"); + let response: InviteProtonUserResponse = self.http.post(&path, &request).await?; + created.push(invitation_from_dto(&share_id, response.invitation)); + } + + timer.success(); + Ok(created) + } + + /// List the members (accepted invitations) of the share on a node. + /// + /// Returns an empty list when the node is not shared. + pub async fn list_share_members(&self, uid: &NodeUid) -> Result> { + let mut timer = self.telemetry.start("list_share_members"); + let members = match self.node_share_id(uid).await? { + Some(share_id) => self.list_share_members_inner(&share_id).await?, + None => Vec::new(), + }; + timer.success(); + Ok(members) + } + + /// List the pending Proton-user invitations of the share on a node. + /// + /// Returns an empty list when the node is not shared. + pub async fn list_share_invitations(&self, uid: &NodeUid) -> Result> { + let mut timer = self.telemetry.start("list_share_invitations"); + let invitations = match self.node_share_id(uid).await? { + Some(share_id) => self.list_share_invitations_inner(&share_id).await?, + None => Vec::new(), + }; + timer.success(); + Ok(invitations) + } + + /// Change a member's role (`PUT v2/shares/{sid}/members/{mid}`). + pub async fn update_member_role(&self, member: &ShareMember, role: MemberRole) -> Result<()> { + let permissions = role.to_permissions().ok_or_else(|| { + ProtonError::invalid_operation("cannot set a member to the inherited role") + })?; + let path = format!( + "v2/shares/{}/members/{}", + member.share_id, member.membership_id + ); + let _: proton_sdk::api::ApiResponse = self + .http + .put(&path, &UpdatePermissionsRequest { permissions }) + .await?; + Ok(()) + } + + /// Remove a member from a share, revoking their access + /// (`DELETE v2/shares/{sid}/members/{mid}`). + pub async fn remove_member(&self, member: &ShareMember) -> Result<()> { + self.remove_share_member(&member.share_id, &member.membership_id) + .await + } + + /// Change a pending invitation's role + /// (`PUT v2/shares/{sid}/invitations/{iid}`). + pub async fn update_invitation_role( + &self, + invitation: &ShareInvitation, + role: MemberRole, + ) -> Result<()> { + let permissions = role.to_permissions().ok_or_else(|| { + ProtonError::invalid_operation("cannot set an invitation to the inherited role") + })?; + let path = format!( + "v2/shares/{}/invitations/{}", + invitation.share_id, invitation.invitation_id + ); + let _: proton_sdk::api::ApiResponse = self + .http + .put(&path, &UpdatePermissionsRequest { permissions }) + .await?; + Ok(()) + } + + /// Revoke a pending invitation + /// (`DELETE v2/shares/{sid}/invitations/{iid}`). + pub async fn delete_invitation(&self, invitation: &ShareInvitation) -> Result<()> { + let path = format!( + "v2/shares/{}/invitations/{}", + invitation.share_id, invitation.invitation_id + ); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + Ok(()) + } + + /// Create a public share link on a node, returning the shareable URL. + /// + /// Ported from the TypeScript SDK's public-link flow: a random 12-character + /// link password (optionally suffixed with `custom_password`) protects the + /// share. The share session key is wrapped with the SRP-salted passphrase + /// (`SharePassphraseKeyPacket`), the password is stored encrypted to the + /// owner's address key, and an SRP verifier lets the server authenticate + /// visitors without learning the password. `role` must be Viewer or Editor + /// (public links cannot grant Admin). `expiration_time` is an optional Unix + /// timestamp. The returned [`PublicLink::url`] carries the secret fragment. + pub async fn create_public_link( + &self, + uid: &NodeUid, + role: MemberRole, + custom_password: Option<&str>, + expiration_time: Option, + ) -> Result { + let mut timer = self.telemetry.start("create_public_link"); + + let permissions = match role { + MemberRole::Viewer | MemberRole::Editor => role.to_permissions().unwrap(), + _ => { + return Err(ProtonError::invalid_operation( + "a public link can only grant the Viewer or Editor role", + )); + } + }; + + let (share_id, share_session_key, creator_email, address_key) = + self.ensure_node_share(uid).await?; + + // The URL fragment is the generated password; the encryption password is + // the generated password with any custom password appended. + let generated = generate_public_link_password(); + let full_password = match custom_password { + Some(custom) if !custom.is_empty() => format!("{generated}{custom}"), + _ => generated.clone(), + }; + + // Wrap the share session key with the SRP-salted passphrase, and store the + // password encrypted to the owner's address key. + let key_salt = generate_key_salt(); + let salted_passphrase = derive_key_passphrase(full_password.as_bytes(), &key_salt)?; + let share_passphrase_key_packet = + base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?); + let armored_password = address_key.encrypt(full_password.as_bytes())?; + + // SRP verifier over the link password, against a fresh signed modulus. + let modulus = self.fetch_srp_modulus().await?; + let verifier = generate_verifier( + full_password.as_bytes(), + &modulus.modulus, + DEFAULT_BIT_LENGTH, + )?; + + let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) { + 3 // random + custom password + } else { + 2 // random password only + }; + + let request = CreatePublicLinkRequest { + creator_email, + permissions, + flags, + expiration_time, + share_password_salt: base64_encode(key_salt), + share_passphrase_key_packet, + password: armored_password, + url_password_salt: verifier.salt, + srp_verifier: verifier.verifier, + srp_modulus_id: modulus.modulus_id, + max_accesses: 0, + }; + + let path = format!("shares/{share_id}/urls"); + let response: CreatePublicLinkResponse = self.http.post(&path, &request).await?; + + timer.success(); + Ok(PublicLink { + share_id, + public_link_id: response.share_url.share_url_id, + url: Some(format!("{}#{generated}", response.share_url.public_url)), + role, + creation_time: now_epoch_seconds(), + expiration_time, + has_custom_password: flags == 3, + }) + } + + /// The public link on a node, if one exists (metadata only — the secret URL + /// fragment is not reconstructed). + pub async fn get_public_link(&self, uid: &NodeUid) -> Result> { + let mut timer = self.telemetry.start("get_public_link"); + let link = match self.node_share_id(uid).await? { + Some(share_id) => { + let path = format!("shares/{share_id}/urls"); + let response: ShareUrlsResponse = self.http.get(&path).await?; + response + .share_urls + .into_iter() + .next() + .map(|dto| PublicLink { + share_id, + public_link_id: dto.share_url_id, + url: None, + role: MemberRole::from_permissions(dto.permissions), + creation_time: dto.create_time, + expiration_time: dto.expiration_time, + has_custom_password: dto.flags == 3, + }) + } + None => None, + }; + timer.success(); + Ok(link) + } + + /// Remove a public link, revoking access via the URL + /// (`DELETE shares/{sid}/urls/{urlID}`). + pub async fn remove_public_link(&self, link: &PublicLink) -> Result<()> { + let path = format!("shares/{}/urls/{}", link.share_id, link.public_link_id); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + Ok(()) + } + + /// List invitations addressed to the current user (shared *with* me), + /// pending accept or reject. + /// + /// Pages `GET v2/shares/invitations`, then fetches and decrypts each + /// invitation's detail so the caller has the shared item's name. A single + /// invitation that fails to load is logged and skipped rather than failing + /// the whole listing. Mirrors JS `iterateInvitations`. + pub async fn list_incoming_invitations(&self) -> Result> { + let mut timer = self.telemetry.start("list_incoming_invitations"); + let mut ids = Vec::new(); + let mut anchor: Option = None; + loop { + let path = match &anchor { + Some(a) => format!("v2/shares/invitations?AnchorID={a}"), + None => "v2/shares/invitations".to_string(), + }; + let page: InvitationsListResponse = self.http.get(&path).await?; + for item in page.invitations { + ids.push(item.invitation_id); + } + anchor = page.anchor_id.filter(|a| !a.is_empty()); + if !page.more || anchor.is_none() { + break; + } + } + + let mut invitations = Vec::with_capacity(ids.len()); + for id in ids { + match self.incoming_invitation_detail(&id).await { + Ok(inv) => invitations.push(inv), + Err(e) => tracing::warn!(invitation = %id, "failed to load invitation: {e}"), + } + } + timer.success(); + Ok(invitations) + } + + /// Accept an invitation addressed to us, gaining access to the shared node. + /// + /// Fetches the invitation, decrypts its key packet with our address keys to + /// recover the share session key, signs the key under the member context, and + /// posts the signature. Mirrors JS `acceptInvitation`. + pub async fn accept_invitation(&self, invitation_id: &str) -> Result<()> { + let mut timer = self.telemetry.start("accept_invitation"); + let detail = self.get_incoming_invitation(invitation_id).await?; + + let (keys, signing_key) = self + .own_address_keys(&detail.invitation.invitee_email) + .await?; + let signature = accept_invitation(&detail.invitation.key_packet, &keys, &signing_key)?; + + let path = format!("v2/shares/invitations/{invitation_id}/accept"); + let _: proton_sdk::api::ApiResponse = self + .http + .post( + &path, + &AcceptInvitationRequest { + session_key_signature: signature, + }, + ) + .await?; + timer.success(); + Ok(()) + } + + /// Reject an invitation addressed to us, declining access. + pub async fn reject_invitation(&self, invitation_id: &str) -> Result<()> { + let path = format!("v2/shares/invitations/{invitation_id}/reject"); + let _: proton_sdk::api::ApiResponse = self.http.post(&path, &serde_json::json!({})).await?; + Ok(()) + } + + /// Invite a mixed set of email addresses to a node's share, routing each to + /// the right flow: addresses that resolve to a Proton account get a normal + /// (encrypted-key-packet) invitation via [`share_node`](Self::share_node); + /// the rest get an [external invitation](Self::invite_external_users). Returns + /// `(proton_invited, external_invited)` counts. Already-invited or existing + /// members are skipped by the underlying calls. + pub async fn invite_users( + &self, + uid: &NodeUid, + invitees: &[(String, MemberRole)], + email_message: Option<&str>, + ) -> Result<(usize, usize)> { + let mut proton: Vec<(String, MemberRole)> = Vec::new(); + let mut external: Vec<(String, MemberRole)> = Vec::new(); + for (email, role) in invitees { + // A Proton account exposes at least one public key; an address with + // none is external and cannot receive an encrypted key packet. + if self.account.public_keys(email).await.is_empty() { + external.push((email.clone(), *role)); + } else { + proton.push((email.clone(), *role)); + } + } + + let proton_invited = if proton.is_empty() { + 0 + } else { + self.share_node(uid, &proton, email_message).await?.len() + }; + let external_invited = if external.is_empty() { + 0 + } else { + self.invite_external_users(uid, &external, email_message) + .await? + .len() + }; + Ok((proton_invited, external_invited)) + } + + /// Invite non-Proton (external) email addresses to a node's share. + /// + /// The invitees have no Proton keys, so each gets an external invitation: the + /// inviter signs `"{email}|{base64(share session key)}"` under the external + /// context. When an invitee later signs up, the server converts the pending + /// invitation to a normal membership. Skips anyone already invited (Proton or + /// external) or already a member. Returns the freshly created invitations. + pub async fn invite_external_users( + &self, + uid: &NodeUid, + invitees: &[(String, MemberRole)], + email_message: Option<&str>, + ) -> Result> { + let mut timer = self.telemetry.start("invite_external_users"); + + let (share_id, share_session_key, _inviter_email, inviter_key) = + self.ensure_node_share(uid).await?; + let (address_id, _, _) = self.membership_address().await?; + + let already: std::collections::HashSet = self + .list_external_invitations_inner(&share_id) + .await? + .iter() + .map(|i| i.invitee_email.to_lowercase()) + .chain( + self.list_share_invitations_inner(&share_id) + .await? + .iter() + .map(|i| i.invitee_email.to_lowercase()), + ) + .chain( + self.list_share_members_inner(&share_id) + .await? + .iter() + .map(|m| m.email.to_lowercase()), + ) + .collect(); + + let mut created = Vec::new(); + for (email, role) in invitees { + if already.contains(&email.to_lowercase()) { + tracing::info!(%email, "skipping already-invited/member for external share"); + continue; + } + let permissions = role.to_permissions().ok_or_else(|| { + ProtonError::invalid_operation("cannot invite a user with the inherited role") + })?; + let signature = encrypt_external_invitation(&share_session_key, &inviter_key, email)?; + + let request = InviteExternalUserRequest { + external_invitation: ExternalInvitationDto { + inviter_address_id: address_id.clone(), + invitee_email: email.clone(), + permissions, + external_invitation_signature: signature, + }, + email_details: InviteEmailDetailsDto { + message: email_message.map(str::to_string), + item_name: None, + }, + }; + let path = format!("v2/shares/{share_id}/external-invitations"); + let response: InviteExternalUserResponse = self.http.post(&path, &request).await?; + created.push(external_invitation_from_dto( + &share_id, + response.external_invitation, + )); + } + + timer.success(); + Ok(created) + } + + /// List the pending external (non-Proton) invitations on a node's share. + /// Returns an empty list when the node is not shared. + pub async fn list_external_invitations( + &self, + uid: &NodeUid, + ) -> Result> { + let mut timer = self.telemetry.start("list_external_invitations"); + let invitations = match self.node_share_id(uid).await? { + Some(share_id) => self.list_external_invitations_inner(&share_id).await?, + None => Vec::new(), + }; + timer.success(); + Ok(invitations) + } + + /// Revoke a pending external invitation + /// (`DELETE v2/shares/{sid}/external-invitations/{iid}`). + pub async fn delete_external_invitation(&self, invitation: &ExternalInvitation) -> Result<()> { + let path = format!( + "v2/shares/{}/external-invitations/{}", + invitation.share_id, invitation.invitation_id + ); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + Ok(()) + } + + // ---- bookmarks --------------------------------------------------------- + + /// List the public links the user has saved to their account + /// (`GET v2/shared-bookmarks`). The URL is always recovered; the item name is + /// only present when the SRP-derived share key could be decrypted (not yet). + pub async fn list_bookmarks(&self) -> Result> { + let mut timer = self.telemetry.start("list_bookmarks"); + let response: BookmarksResponse = self.http.get("v2/shared-bookmarks").await?; + + // Bookmark URL passwords are encrypted to our My Files share address key. + let (_, email, _) = self.membership_address().await?; + let (address_keys, _) = self.own_address_keys(&email).await?; + + let mut bookmarks = Vec::with_capacity(response.bookmarks.len()); + for dto in response.bookmarks { + let Some(enc) = dto.encrypted_url_password.as_deref() else { + continue; + }; + let Ok(bytes) = decrypt_armored_with_keys(enc, &address_keys) else { + continue; + }; + // The stored secret is the URL password with any custom password + // appended; the URL only needs the leading generated portion, but the + // API does not tell us the split, so the fragment is the whole string. + let url_password = String::from_utf8_lossy(&bytes).into_owned(); + + bookmarks.push(Bookmark { + url: format!( + "https://drive.proton.me/urls/{}#{url_password}", + dto.token.token + ), + token: dto.token.token, + // Name decryption needs the SRP-derived share key, not yet ported; + // the URL is enough to open the link. + node_name: None, + is_folder: dto.token.link_type == 1, + creation_time: dto.create_time, + }); + } + timer.success(); + Ok(bookmarks) + } + + /// Save a public link to the account as a bookmark + /// (`POST v2/urls/{token}/bookmark`). `url` is the full public URL including + /// the `#password` fragment; `custom_password` is required only when the link + /// is additionally password-protected. + pub async fn create_bookmark(&self, url: &str, custom_password: Option<&str>) -> Result<()> { + let mut timer = self.telemetry.start("create_bookmark"); + let (token, url_password) = parse_public_link_url(url)?; + + let (address_id, email, _) = self.membership_address().await?; + let (_, address_key) = self.own_address_keys(&email).await?; + let address_key_id = self.address_primary_key_id(&address_id).await?; + + // The stored secret is the URL password concatenated with any custom + // password, encrypted + signed to our own address key. + let concatenated = format!("{url_password}{}", custom_password.unwrap_or("")); + let encrypted_url_password = + address_key.encrypt_and_sign(&address_key, concatenated.as_bytes(), false, false)?; + + let request = CreateBookmarkRequest { + bookmark_share_url: BookmarkShareUrlDto { + encrypted_url_password, + address_id, + address_key_id, + }, + }; + let path = format!("v2/urls/{token}/bookmark"); + let _: proton_sdk::api::ApiResponse = self.http.post(&path, &request).await?; + timer.success(); + Ok(()) + } + + /// Remove a saved bookmark (`DELETE v2/urls/{token}/bookmark`). + pub async fn delete_bookmark(&self, token: &str) -> Result<()> { + let path = format!("v2/urls/{token}/bookmark"); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + Ok(()) + } + + // ---- sharing internals ------------------------------------------------- + + /// Fetch a single incoming invitation's encrypted detail by id. + async fn get_incoming_invitation( + &self, + invitation_id: &str, + ) -> Result { + let path = format!("v2/shares/invitations/{invitation_id}"); + self.http.get(&path).await + } + + /// Fetch and decrypt a single incoming invitation into its public form. + async fn incoming_invitation_detail(&self, invitation_id: &str) -> Result { + let detail = self.get_incoming_invitation(invitation_id).await?; + let node_uid = NodeUid::new( + VolumeId::from(detail.share.volume_id.clone()), + LinkId::from(detail.link.link_id.clone()), + ); + let node_name = self.decrypt_invitation_name(&detail).await; + + Ok(IncomingInvitation { + invitation_id: detail.invitation.invitation_id, + inviter_email: detail.invitation.inviter_email, + invitee_email: detail.invitation.invitee_email, + role: MemberRole::from_permissions(detail.invitation.permissions), + invitation_time: detail.invitation.create_time, + node_uid, + node_name, + is_folder: detail.link.link_type == 1, + }) + } + + /// Decrypt the shared item's name from an invitation, using the invitee's own + /// keys to unlock the share key. A failure yields `None` — the invitation is + /// still actionable (accept/reject) without a readable name. + async fn decrypt_invitation_name(&self, detail: &InvitationDetailsResponse) -> Option { + let (keys, _) = self + .own_address_keys(&detail.invitation.invitee_email) + .await + .ok()?; + let passphrase = keys + .iter() + .find_map(|k| k.decrypt_armored_message(&detail.share.passphrase).ok())?; + let share_key = PrivateKey::from_armored(&detail.share.share_key, &passphrase).ok()?; + let name_bytes = share_key.decrypt_armored_message(&detail.link.name).ok()?; + String::from_utf8(name_bytes).ok() + } + + /// The current user's own address keys for `email`: all its private keys (for + /// decryption) and the primary signing key. + async fn own_address_keys(&self, email: &str) -> Result<(Vec, PrivateKey)> { + let email_lc = email.to_lowercase(); + let address = self + .account + .addresses() + .await? + .into_iter() + .find(|a| a.email.to_lowercase() == email_lc) + .ok_or_else(|| ProtonError::invalid_operation(format!("no own address for {email}")))?; + let keys = self.account.address_private_keys(&address.id).await?; + let signing_key = keys + .get(address.primary_key_index) + .cloned() + .ok_or_else(|| ProtonError::invalid_operation("address has no primary key"))?; + Ok((keys, signing_key)) + } + + async fn list_external_invitations_inner( + &self, + share_id: &ShareId, + ) -> Result> { + let path = format!("v2/shares/{share_id}/external-invitations"); + let response: ExternalInvitationsResponse = self.http.get(&path).await?; + Ok(response + .external_invitations + .into_iter() + .map(|i| external_invitation_from_dto(share_id, i)) + .collect()) + } + + /// Fetch a fresh signed SRP modulus (`GET auth/v4/modulus`, root route). + async fn fetch_srp_modulus(&self) -> Result { + // The modulus endpoint lives at the API root, not under `drive/`, and is + // a GET (the server rejects POST with 405 "Allow: GET"). + let root = self.http.with_base_route(""); + root.get("auth/v4/modulus").await + } + + /// The id of the standard share on a node, if it is shared. + async fn node_share_id(&self, uid: &NodeUid) -> Result> { + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + Ok(details + .links + .into_iter() + .find(|detail| detail.link.id == uid.link_id) + .and_then(|detail| detail.sharing) + .map(|sharing| sharing.share_id)) + } + + /// Get-or-create the standard share on a node, returning its id, passphrase + /// session key (for inviting), and the owning address's email + signing key. + async fn ensure_node_share( + &self, + uid: &NodeUid, + ) -> Result<(ShareId, ContentKey, String, PrivateKey)> { + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let detail = details + .links + .into_iter() + .find(|detail| detail.link.id == uid.link_id) + .ok_or_else(|| ProtonError::invalid_operation("node not found"))?; + let link = &detail.link; + + let parent_key = self.resolve_parent_key(&uid.volume_id, link).await?; + let node_key = decrypt_link(&parent_key, link)?.node_key; + + let (address_id, inviter_email, address_key) = self.membership_address().await?; + + if let Some(sharing) = detail.sharing { + // Already shared: recover the existing share's passphrase session key + // (the passphrase is encrypted to the node key) so we can invite. + let path = format!("shares/{}", sharing.share_id); + let response: ShareResponse = self.http.get(&path).await?; + let share_session_key = + node_key.recover_message_session_key(&response.share.passphrase)?; + return Ok(( + sharing.share_id, + share_session_key, + inviter_email, + address_key, + )); + } + + // Not shared yet: lift the node's passphrase + name session keys, build a + // fresh share bound to the node + owning address, and create it. + let node_passphrase_sk = parent_key.recover_message_session_key(&link.passphrase)?; + let node_name_sk = parent_key.recover_message_session_key(&link.name)?; + let material = build_standard_share_material( + &node_key, + &node_passphrase_sk, + &node_name_sk, + &address_key, + )?; + + let request = CreateShareRequest { + root_link_id: uid.link_id.clone(), + address_id, + name: "New Share".to_string(), + share_key: material.share_key_armored, + share_passphrase: material.share_passphrase, + share_passphrase_signature: material.share_passphrase_signature, + passphrase_key_packet: material.passphrase_key_packet, + name_key_packet: material.name_key_packet, + }; + let path = format!("volumes/{}/shares", uid.volume_id); + let response: CreateShareResponse = self.http.post(&path, &request).await?; + + Ok(( + response.share.id, + material.share_session_key, + inviter_email, + address_key, + )) + } + + async fn list_share_members_inner(&self, share_id: &ShareId) -> Result> { + let path = format!("v2/shares/{share_id}/members"); + let response: ShareMembersResponse = self.http.get(&path).await?; + Ok(response + .members + .into_iter() + .map(|m| ShareMember { + share_id: share_id.clone(), + membership_id: m.member_id, + email: m.email, + added_by_email: m.inviter_email, + role: MemberRole::from_permissions(m.permissions), + invitation_time: m.create_time, + }) + .collect()) + } + + async fn list_share_invitations_inner( + &self, + share_id: &ShareId, + ) -> Result> { + let path = format!("v2/shares/{share_id}/invitations"); + let response: ShareInvitationsResponse = self.http.get(&path).await?; + Ok(response + .invitations + .into_iter() + .map(|i| invitation_from_dto(share_id, i)) + .collect()) + } + /// The account's registered devices, with their root-folder names decrypted. /// /// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then @@ -1731,8 +2521,28 @@ impl ProtonDriveClient { .resolve_membership_address(membership_address_id) .await?; + // Proton's rename endpoint keeps the link's existing name *key packet* and + // replaces only the *data packet*, so the new name must be encrypted under + // the **same** session key as the current name (otherwise the retained key + // packet no longer decrypts the data packet). Recover that session key from + // the current root-folder name and reuse it. Mirrors C# + // `DeviceOperations.RenameRootFolderAsync` (`GetNodeMetadata` → + // `NameSessionKey` → `DeviceCrypto.GetRenameRequest`). + let uid = &device.root_folder_uid; + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let current_name = details + .links + .into_iter() + .find(|detail| detail.link.id == uid.link_id) + .map(|detail| detail.link.name) + .ok_or_else(|| { + ProtonError::invalid_operation(format!("device root folder {uid} not found")) + })?; + let name_session_key = share_key.recover_message_session_key(¤t_name)?; let encrypted_name = - share_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?; + name_session_key.encrypt_and_sign_text_to(&share_key, &signing_key, name.as_bytes())?; let request = RenameLinkRequest { name: encrypted_name, name_hash: String::new(), @@ -3089,6 +3899,80 @@ fn is_my_files_missing(error: &ProtonError) -> bool { ) } +/// base64-encode bytes with the standard alphabet. +fn base64_encode(bytes: impl AsRef<[u8]>) -> String { + BASE64.encode(bytes) +} + +/// The alphabet for generated public-link passwords (JS `generatePublicLinkPassword`). +const PUBLIC_LINK_PASSWORD_CHARSET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +/// Generated public-link password length (JS `PUBLIC_LINK_GENERATED_PASSWORD_LENGTH`). +const PUBLIC_LINK_PASSWORD_LEN: usize = 12; + +/// Generate a random 12-character alphanumeric public-link password. +fn generate_public_link_password() -> String { + let mut raw = [0u8; PUBLIC_LINK_PASSWORD_LEN]; + getrandom::fill(&mut raw).expect("system RNG"); + raw.iter() + .map(|b| { + PUBLIC_LINK_PASSWORD_CHARSET[*b as usize % PUBLIC_LINK_PASSWORD_CHARSET.len()] as char + }) + .collect() +} + +/// Generate a fresh 16-byte key salt (Proton `generateKeySalt`). +fn generate_key_salt() -> [u8; 16] { + let mut salt = [0u8; 16]; + getrandom::fill(&mut salt).expect("system RNG"); + salt +} + +/// Build a [`ShareInvitation`] model from its wire DTO. +fn invitation_from_dto(share_id: &ShareId, dto: ShareInvitationDto) -> ShareInvitation { + ShareInvitation { + share_id: share_id.clone(), + invitation_id: dto.invitation_id, + invitee_email: dto.invitee_email, + inviter_email: dto.inviter_email, + role: MemberRole::from_permissions(dto.permissions), + invitation_time: dto.create_time, + } +} + +fn external_invitation_from_dto( + share_id: &ShareId, + dto: ExternalInvitationResponseDto, +) -> ExternalInvitation { + ExternalInvitation { + share_id: share_id.clone(), + invitation_id: dto.external_invitation_id, + invitee_email: dto.invitee_email, + inviter_email: dto.inviter_email, + role: MemberRole::from_permissions(dto.permissions), + invitation_time: dto.create_time, + state: ExternalInvitationState::from_raw(dto.state), + } +} + +/// Split a public-link URL (`https://drive.proton.me/urls/{token}#{password}`) +/// into its token and password. Mirrors JS `getTokenAndPasswordFromUrl`: the +/// token is the last path segment, the password the fragment after `#`. +fn parse_public_link_url(url: &str) -> Result<(String, String)> { + let invalid = || ProtonError::invalid_operation("invalid public link url"); + let (before_hash, password) = url.split_once('#').ok_or_else(invalid)?; + let token = before_hash + .trim_end_matches('/') + .rsplit('/') + .next() + .filter(|t| !t.is_empty()) + .ok_or_else(invalid)?; + if password.is_empty() { + return Err(invalid()); + } + Ok((token.to_string(), password.to_string())) +} + /// The current Unix epoch, in seconds. fn now_epoch_seconds() -> i64 { std::time::SystemTime::now() diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 8409475..f60f133 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -1119,3 +1119,417 @@ mod tests { assert!(ShareTargetType::from_raw(6).is_none()); } } + +// --------------------------------------------------------------------------- +// Sharing: creating shares, inviting Proton users, members + invitations. +// Ported from the TypeScript SDK (`internal/sharing/apiService.ts`); the C# +// public SDK does not expose share creation. +// --------------------------------------------------------------------------- + +/// `POST volumes/{volumeID}/shares` — create a standard share on a node. +#[derive(Debug, Serialize)] +pub struct CreateShareRequest { + #[serde(rename = "RootLinkID")] + pub root_link_id: LinkId, + #[serde(rename = "AddressID")] + pub address_id: AddressId, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "ShareKey")] + pub share_key: String, + #[serde(rename = "SharePassphrase")] + pub share_passphrase: String, + #[serde(rename = "SharePassphraseSignature")] + pub share_passphrase_signature: String, + #[serde(rename = "PassphraseKeyPacket")] + pub passphrase_key_packet: String, + #[serde(rename = "NameKeyPacket")] + pub name_key_packet: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateShareResponse { + #[serde(rename = "Share")] + pub share: CreatedShareDto, +} + +#[derive(Debug, Deserialize)] +pub struct CreatedShareDto { + #[serde(rename = "ID")] + pub id: ShareId, + #[serde(rename = "EditorsCanShare", default)] + pub editors_can_share: bool, +} + +/// `POST v2/shares/{shareID}/invitations` — invite a Proton user. +#[derive(Debug, Serialize)] +pub struct InviteProtonUserRequest { + #[serde(rename = "Invitation")] + pub invitation: InviteProtonUserInvitationDto, + #[serde(rename = "EmailDetails")] + pub email_details: InviteEmailDetailsDto, +} + +#[derive(Debug, Serialize)] +pub struct InviteProtonUserInvitationDto { + #[serde(rename = "InviterEmail")] + pub inviter_email: String, + #[serde(rename = "InviteeEmail")] + pub invitee_email: String, + #[serde(rename = "Permissions")] + pub permissions: i32, + #[serde(rename = "KeyPacket")] + pub key_packet: String, + #[serde(rename = "KeyPacketSignature")] + pub key_packet_signature: String, + #[serde( + rename = "ExternalInvitationID", + skip_serializing_if = "Option::is_none" + )] + pub external_invitation_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct InviteEmailDetailsDto { + #[serde(rename = "Message", skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(rename = "ItemName", skip_serializing_if = "Option::is_none")] + pub item_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct InviteProtonUserResponse { + #[serde(rename = "Invitation")] + pub invitation: ShareInvitationDto, +} + +/// `GET v2/shares/{shareID}/invitations` +#[derive(Debug, Deserialize)] +pub struct ShareInvitationsResponse { + #[serde(rename = "Invitations", default)] + pub invitations: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ShareInvitationDto { + #[serde(rename = "InvitationID")] + pub invitation_id: String, + #[serde(rename = "InviterEmail")] + pub inviter_email: String, + #[serde(rename = "InviteeEmail")] + pub invitee_email: String, + #[serde(rename = "Permissions", default)] + pub permissions: Option, + #[serde(rename = "CreateTime", default)] + pub create_time: i64, +} + +/// `GET v2/shares/{shareID}/members` +#[derive(Debug, Deserialize)] +pub struct ShareMembersResponse { + #[serde(rename = "Members", default)] + pub members: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ShareMemberDto { + #[serde(rename = "MemberID")] + pub member_id: ShareMembershipId, + #[serde(rename = "InviterEmail")] + pub inviter_email: String, + #[serde(rename = "Email")] + pub email: String, + #[serde(rename = "Permissions", default)] + pub permissions: Option, + #[serde(rename = "CreateTime", default)] + pub create_time: i64, +} + +/// `PUT v2/shares/{shareID}/members/{memberID}` and the invitation equivalent. +#[derive(Debug, Serialize)] +pub struct UpdatePermissionsRequest { + #[serde(rename = "Permissions")] + pub permissions: i32, +} + +// --------------------------------------------------------------------------- +// Incoming invitations: invitations addressed to the current user, and +// accept/reject. `GET v2/shares/invitations` lists them; the detail, accept and +// reject routes are keyed by invitation id alone. +// --------------------------------------------------------------------------- + +/// `GET v2/shares/invitations` — the invitations where we are the invitee. +#[derive(Debug, Deserialize)] +pub struct InvitationsListResponse { + #[serde(rename = "Invitations", default)] + pub invitations: Vec, + #[serde(rename = "More", default)] + pub more: bool, + #[serde(rename = "AnchorID", default)] + pub anchor_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct InvitationListItemDto { + #[serde(rename = "InvitationID")] + pub invitation_id: String, + #[serde(rename = "ShareID", default)] + pub share_id: String, + #[serde(rename = "ShareTargetType", default)] + pub share_target_type: i32, +} + +/// `GET v2/shares/invitations/{invitationID}` — the encrypted invitation, the +/// share crypto needed to decrypt it, and the shared node's link. +#[derive(Debug, Deserialize)] +pub struct InvitationDetailsResponse { + #[serde(rename = "Invitation")] + pub invitation: IncomingInvitationDto, + #[serde(rename = "Share")] + pub share: InvitationShareDto, + #[serde(rename = "Link")] + pub link: InvitationLinkDto, +} + +#[derive(Debug, Deserialize)] +pub struct IncomingInvitationDto { + #[serde(rename = "InvitationID")] + pub invitation_id: String, + #[serde(rename = "InviterEmail")] + pub inviter_email: String, + #[serde(rename = "InviteeEmail")] + pub invitee_email: String, + #[serde(rename = "KeyPacket")] + pub key_packet: String, + #[serde(rename = "KeyPacketSignature", default)] + pub key_packet_signature: String, + #[serde(rename = "Permissions", default)] + pub permissions: Option, + #[serde(rename = "CreateTime", default)] + pub create_time: i64, +} + +#[derive(Debug, Deserialize)] +pub struct InvitationShareDto { + #[serde(rename = "ShareKey")] + pub share_key: String, + #[serde(rename = "Passphrase")] + pub passphrase: String, + #[serde(rename = "CreatorEmail", default)] + pub creator_email: String, + #[serde(rename = "VolumeID")] + pub volume_id: String, + #[serde(rename = "ShareTargetType", default)] + pub share_target_type: i32, +} + +#[derive(Debug, Deserialize)] +pub struct InvitationLinkDto { + #[serde(rename = "LinkID")] + pub link_id: String, + #[serde(rename = "Type", default)] + pub link_type: i32, + #[serde(rename = "MIMEType", default)] + pub mime_type: Option, + #[serde(rename = "Name")] + pub name: String, +} + +/// `POST v2/shares/invitations/{invitationID}/accept`. +#[derive(Debug, Serialize)] +pub struct AcceptInvitationRequest { + #[serde(rename = "SessionKeySignature")] + pub session_key_signature: String, +} + +// --------------------------------------------------------------------------- +// External (non-Proton) invitations. Same share-invitation model, but the +// invitee has no Proton key, so instead of a key packet the inviter carries a +// detached signature over the invitee email + share session key. +// --------------------------------------------------------------------------- + +/// `POST v2/shares/{shareID}/external-invitations`. +#[derive(Debug, Serialize)] +pub struct InviteExternalUserRequest { + #[serde(rename = "ExternalInvitation")] + pub external_invitation: ExternalInvitationDto, + #[serde(rename = "EmailDetails")] + pub email_details: InviteEmailDetailsDto, +} + +#[derive(Debug, Serialize)] +pub struct ExternalInvitationDto { + #[serde(rename = "InviterAddressID")] + pub inviter_address_id: AddressId, + #[serde(rename = "InviteeEmail")] + pub invitee_email: String, + #[serde(rename = "Permissions")] + pub permissions: i32, + #[serde(rename = "ExternalInvitationSignature")] + pub external_invitation_signature: String, +} + +#[derive(Debug, Deserialize)] +pub struct InviteExternalUserResponse { + #[serde(rename = "ExternalInvitation")] + pub external_invitation: ExternalInvitationResponseDto, +} + +/// `GET v2/shares/{shareID}/external-invitations`. +#[derive(Debug, Deserialize)] +pub struct ExternalInvitationsResponse { + #[serde(rename = "ExternalInvitations", default)] + pub external_invitations: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ExternalInvitationResponseDto { + #[serde(rename = "ExternalInvitationID")] + pub external_invitation_id: String, + #[serde(rename = "InviterEmail")] + pub inviter_email: String, + #[serde(rename = "InviteeEmail")] + pub invitee_email: String, + #[serde(rename = "Permissions", default)] + pub permissions: Option, + #[serde(rename = "CreateTime", default)] + pub create_time: i64, + /// `1` = pending (invitee has no Proton account yet), else user-registered. + #[serde(rename = "State", default)] + pub state: i32, +} + +// --------------------------------------------------------------------------- +// Bookmarks: public links the user has saved to their account. +// --------------------------------------------------------------------------- + +/// `GET v2/shared-bookmarks`. +#[derive(Debug, Deserialize)] +pub struct BookmarksResponse { + #[serde(rename = "Bookmarks", default)] + pub bookmarks: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct BookmarkDto { + #[serde(rename = "Token")] + pub token: BookmarkTokenDto, + #[serde(rename = "CreateTime", default)] + pub create_time: i64, + #[serde(rename = "EncryptedUrlPassword", default)] + pub encrypted_url_password: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BookmarkTokenDto { + #[serde(rename = "Token")] + pub token: String, + #[serde(rename = "ShareKey")] + pub share_key: String, + #[serde(rename = "SharePassphrase")] + pub share_passphrase: String, + #[serde(rename = "SharePasswordSalt")] + pub share_password_salt: String, + #[serde(rename = "LinkType", default)] + pub link_type: i32, + #[serde(rename = "MIMEType", default)] + pub mime_type: Option, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NodeKey", default)] + pub node_key: String, + #[serde(rename = "NodePassphrase", default)] + pub node_passphrase: String, + #[serde(rename = "ContentKeyPacket", default)] + pub content_key_packet: Option, +} + +/// `POST v2/urls/{token}/bookmark`. +#[derive(Debug, Serialize)] +pub struct CreateBookmarkRequest { + #[serde(rename = "BookmarkShareURL")] + pub bookmark_share_url: BookmarkShareUrlDto, +} + +#[derive(Debug, Serialize)] +pub struct BookmarkShareUrlDto { + #[serde(rename = "EncryptedUrlPassword")] + pub encrypted_url_password: String, + #[serde(rename = "AddressID")] + pub address_id: AddressId, + #[serde(rename = "AddressKeyID")] + pub address_key_id: AddressKeyId, +} + +// --------------------------------------------------------------------------- +// Public share links (`shares/{shareID}/urls`) + SRP modulus fetch. +// --------------------------------------------------------------------------- + +/// `POST auth/v4/modulus` (root route) — a fresh signed SRP modulus. +#[derive(Debug, Deserialize)] +pub struct ModulusResponse { + #[serde(rename = "Modulus")] + pub modulus: String, + #[serde(rename = "ModulusID")] + pub modulus_id: String, +} + +/// `POST shares/{shareID}/urls` — create a public link. +#[derive(Debug, Serialize)] +pub struct CreatePublicLinkRequest { + #[serde(rename = "CreatorEmail")] + pub creator_email: String, + #[serde(rename = "Permissions")] + pub permissions: i32, + #[serde(rename = "Flags")] + pub flags: i32, + #[serde(rename = "ExpirationTime", skip_serializing_if = "Option::is_none")] + pub expiration_time: Option, + #[serde(rename = "SharePasswordSalt")] + pub share_password_salt: String, + #[serde(rename = "SharePassphraseKeyPacket")] + pub share_passphrase_key_packet: String, + #[serde(rename = "Password")] + pub password: String, + #[serde(rename = "UrlPasswordSalt")] + pub url_password_salt: String, + #[serde(rename = "SRPVerifier")] + pub srp_verifier: String, + #[serde(rename = "SRPModulusID")] + pub srp_modulus_id: String, + #[serde(rename = "MaxAccesses")] + pub max_accesses: i32, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePublicLinkResponse { + #[serde(rename = "ShareURL")] + pub share_url: ShareUrlDto, +} + +/// `GET shares/{shareID}/urls` +#[derive(Debug, Deserialize)] +pub struct ShareUrlsResponse { + #[serde(rename = "ShareURLs", default)] + pub share_urls: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ShareUrlDto { + #[serde(rename = "ShareURLID")] + pub share_url_id: String, + #[serde(rename = "ShareID", default)] + pub share_id: Option, + #[serde(rename = "PublicUrl", default)] + pub public_url: String, + #[serde(rename = "CreateTime", default)] + pub create_time: i64, + #[serde(rename = "ExpirationTime", default)] + pub expiration_time: Option, + #[serde(rename = "Permissions", default)] + pub permissions: Option, + #[serde(rename = "Flags", default)] + pub flags: i32, + #[serde(rename = "NumAccesses", default)] + pub num_accesses: i64, +} diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index eb699b3..183f12f 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -41,6 +41,7 @@ mod dtos; mod events; mod node; mod photos; +mod sharing; pub use cache::{CachedNodeInfo, DriveEntityCache}; pub use client::ProtonDriveClient; @@ -50,5 +51,9 @@ pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient}; pub use proton_sdk::account::KeySalt; pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository}; +pub use sharing::{ + Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole, + PublicLink, ShareInvitation, ShareMember, +}; pub use proton_sdk; diff --git a/crates/proton-drive-rs/src/sharing.rs b/crates/proton-drive-rs/src/sharing.rs new file mode 100644 index 0000000..25f2cab --- /dev/null +++ b/crates/proton-drive-rs/src/sharing.rs @@ -0,0 +1,184 @@ +//! Sharing model: member roles, members and pending invitations. +//! +//! Covers *creating* shares — inviting Proton users to a node and (see +//! [`crate::client`]) managing public links — which the C# public SDK does not +//! 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::ids::{NodeUid, ShareId, ShareMembershipId}; + +/// 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)] +pub enum MemberRole { + /// Read-only (`4`). + Viewer, + /// Read + write (`6`). + Editor, + /// Read + write + admin (`22`). + Admin, + /// No explicit permissions on the membership; access is inherited. Only ever + /// *read* back from the API — it cannot be sent when inviting. + Inherited, +} + +impl MemberRole { + /// The permissions bitmask for this role, or `None` for [`MemberRole::Inherited`] + /// (which has no wire representation when inviting). Mirrors JS + /// `memberRoleToPermission`. + pub fn to_permissions(self) -> Option { + match self { + MemberRole::Viewer => Some(4), + MemberRole::Editor => Some(6), + MemberRole::Admin => Some(22), + MemberRole::Inherited => None, + } + } + + /// Map a permissions bitmask back to a role. Mirrors JS + /// `permissionsToMemberRole`: an absent value is inherited; an unknown value + /// degrades to [`MemberRole::Viewer`] (the holder can at least read). + pub fn from_permissions(permissions: Option) -> Self { + match permissions { + None => MemberRole::Inherited, + Some(4) => MemberRole::Viewer, + Some(6) => MemberRole::Editor, + Some(22) => MemberRole::Admin, + Some(_) => MemberRole::Viewer, + } + } +} + +/// A member of a share (an invitation that has been accepted). +#[derive(Debug, Clone)] +pub struct ShareMember { + /// The share this membership belongs to. + pub share_id: ShareId, + /// The membership id (used to update the role or remove the member). + pub membership_id: ShareMembershipId, + /// The member's email address. + pub email: String, + /// The email of the user who added this member. + pub added_by_email: String, + /// The member's role. + pub role: MemberRole, + /// When the member was invited (Unix seconds). + pub invitation_time: i64, +} + +/// A public share link on a node. +#[derive(Debug, Clone)] +pub struct PublicLink { + /// The share this link belongs to. + pub share_id: ShareId, + /// The public-link id (`ShareURLID`; used to remove the link). + pub public_link_id: String, + /// The full shareable URL, including the `#password` fragment when known + /// (present on creation; `None` when only listed without decrypting). + pub url: Option, + /// The role granted to anyone with the link (Viewer or Editor). + pub role: MemberRole, + /// When the link was created (Unix seconds). + pub creation_time: i64, + /// When the link expires (Unix seconds), if an expiry was set. + pub expiration_time: Option, + /// Whether the link is additionally protected by a custom password. + pub has_custom_password: bool, +} + +/// A pending invitation to a Proton user (not yet accepted). +#[derive(Debug, Clone)] +pub struct ShareInvitation { + /// The share this invitation belongs to. + pub share_id: ShareId, + /// The invitation id (used to delete or update the invitation). + pub invitation_id: String, + /// The invitee's email address. + pub invitee_email: String, + /// The email of the inviter. + pub inviter_email: String, + /// The offered role. + pub role: MemberRole, + /// When the invitation was created (Unix seconds). + pub invitation_time: i64, +} + +/// State of an external (non-Proton) invitation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExternalInvitationState { + /// The invitee has not yet created a Proton account. + Pending, + /// The invitee has registered; the invitation can be converted to a member. + UserRegistered, +} + +impl ExternalInvitationState { + /// Map the API's `State` number (`1` = pending, else user-registered). + pub fn from_raw(state: i32) -> Self { + match state { + 1 => Self::Pending, + _ => Self::UserRegistered, + } + } +} + +/// A pending invitation to a non-Proton email address. The invitee must sign up +/// before the share can be finalized; until then it lives as an external invite. +#[derive(Debug, Clone)] +pub struct ExternalInvitation { + /// The share this invitation belongs to. + pub share_id: ShareId, + /// The external-invitation id (used to delete or update it). + pub invitation_id: String, + /// The invitee's email address. + pub invitee_email: String, + /// The email of the inviter. + pub inviter_email: String, + /// The offered role. + pub role: MemberRole, + /// When the invitation was created (Unix seconds). + pub invitation_time: i64, + /// Whether the invitee has registered yet. + pub state: ExternalInvitationState, +} + +/// An invitation addressed to the current user (shared *with* me), pending +/// accept or reject. The [`invitation_id`](Self::invitation_id) is the handle for +/// [`accept_invitation`](crate::ProtonDriveClient::accept_invitation) and +/// [`reject_invitation`](crate::ProtonDriveClient::reject_invitation). +#[derive(Debug, Clone)] +pub struct IncomingInvitation { + /// The invitation id — the handle for accept/reject. + pub invitation_id: String, + /// The email of the user who shared the item. + pub inviter_email: String, + /// The address the invitation was sent to (one of ours). + pub invitee_email: String, + /// The offered role. + pub role: MemberRole, + /// When the invitation was created (Unix seconds). + pub invitation_time: i64, + /// The shared node's uid (once accepted it appears in shared-with-me). + pub node_uid: NodeUid, + /// The shared item's decrypted name, when it could be decrypted. + pub node_name: Option, + /// Whether the shared item is a folder. + pub is_folder: bool, +} + +/// A public link the user has saved to their account ("bookmark"). Opening it +/// visits the public URL; there is no local copy. +#[derive(Debug, Clone)] +pub struct Bookmark { + /// The bookmark's token — the handle for + /// [`delete_bookmark`](crate::ProtonDriveClient::delete_bookmark). + pub token: String, + /// The full public URL, including the `#password` fragment. + pub url: String, + /// The bookmarked item's decrypted name, when it could be decrypted. + pub node_name: Option, + /// Whether the bookmarked item is a folder. + pub is_folder: bool, + /// When the bookmark was created (Unix seconds). + pub creation_time: i64, +} diff --git a/crates/proton-drive-rs/tests/live_devices.rs b/crates/proton-drive-rs/tests/live_devices.rs new file mode 100644 index 0000000..e23418d --- /dev/null +++ b/crates/proton-drive-rs/tests/live_devices.rs @@ -0,0 +1,136 @@ +//! Live integration: device registration and the account quota read surface, +//! against a real Proton account. +//! +//! Skipped by default. Run against the test account with: +//! cargo test -p proton-drive-rs --test live_devices -- --ignored --nocapture +//! +//! The device test registers a throwaway device and deletes it at the end, so +//! the account stays reusable across runs. + +mod common; + +use proton_drive_rs::{DeviceType, ProtonDriveClient}; +use proton_sdk::ids::DeviceUid; + +/// Delete the device; best-effort, logs on failure. +async fn cleanup(client: &ProtonDriveClient, uid: &DeviceUid) { + if let Err(e) = client.delete_device(uid).await { + eprintln!("[cleanup] delete_device failed: {e}"); + } +} + +// --------------------------------------------------------------------------- +// Device lifecycle +// --------------------------------------------------------------------------- + +/// create → enumerate (find + name decrypts) → rename → delete (gone). +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn device_create_enumerate_rename_delete() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let name = format!("dev-{}", common::unique_suffix()); + + // create + let device = client + .create_device(&name, DeviceType::Linux) + .await + .expect("create_device"); + assert_eq!( + device.device_type, + DeviceType::Linux, + "type must round-trip" + ); + assert_eq!( + device.name.as_deref().ok(), + Some(name.as_str()), + "created device name must match" + ); + let uid = device.uid.clone(); + + // enumerate: the new device must appear, and its name must decrypt to what + // we set (this exercises the device-share-key name decryption path, which + // the offline tests can't reach). + let listed = client.enumerate_devices().await.expect("enumerate_devices"); + let found = listed + .iter() + .find(|d| d.uid == uid) + .expect("the created device must appear in the enumeration"); + assert_eq!( + found.name.as_deref().ok(), + Some(name.as_str()), + "enumerated device name must decrypt to the created name" + ); + assert_eq!( + found.device_type, + DeviceType::Linux, + "enumerated device type must match" + ); + + // rename + let renamed = format!("{name}-renamed"); + client + .rename_device(&uid, &renamed) + .await + .expect("rename_device"); + let after = client + .enumerate_devices() + .await + .expect("enumerate_devices (after rename)"); + let found = after + .iter() + .find(|d| d.uid == uid) + .expect("device must still be present after rename"); + assert_eq!( + found.name.as_deref().ok(), + Some(renamed.as_str()), + "rename must take effect and re-decrypt" + ); + + // delete + cleanup(client, &uid).await; + let gone = client + .enumerate_devices() + .await + .expect("enumerate_devices (after delete)"); + assert!( + !gone.iter().any(|d| d.uid == uid), + "device must be gone after deletion" + ); +} + +// --------------------------------------------------------------------------- +// Account quota (read-only) +// --------------------------------------------------------------------------- + +/// The account quota must read back with sane values. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn quota_reads() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let quota = client.quota().await.expect("quota"); + eprintln!( + "[info] quota: used {} / max {} bytes ({:.1}% used)", + quota.used_space, + quota.max_space, + quota.used_fraction() * 100.0 + ); + + assert!(quota.max_space > 0, "a real account has non-zero max space"); + assert!(quota.used_space >= 0, "used space cannot be negative"); + assert!( + quota.used_space <= quota.max_space, + "used space cannot exceed max space" + ); + assert!( + (0.0..=1.0).contains("a.used_fraction()), + "used fraction must be a probability" + ); +} diff --git a/crates/proton-drive-rs/tests/live_sharing.rs b/crates/proton-drive-rs/tests/live_sharing.rs new file mode 100644 index 0000000..15abfd0 --- /dev/null +++ b/crates/proton-drive-rs/tests/live_sharing.rs @@ -0,0 +1,252 @@ +//! Live integration: sharing — public links, standard-share creation, and the +//! read-only shared-with-me / incoming-invitation surfaces, against a real +//! Proton account. +//! +//! Skipped by default. Run against the test account with: +//! cargo test -p proton-drive-rs --test live_sharing -- --ignored --nocapture +//! +//! Each test creates its own throwaway folder and deletes it at the end +//! (deleting the node tears down any share created on it), so the account stays +//! reusable across runs. +//! +//! What is *not* covered here: inviting a real Proton user (`share_node` with a +//! non-empty invitee list) and accept/reject flows. Those need a *second* +//! account, and inviting a stranger would send them a real email — out of scope +//! for a single-account CI harness. We still exercise share *creation* (the +//! crypto + `POST shares`) by calling `share_node` with an empty invitee list, +//! which provisions the standard share without emailing anyone. + +mod common; + +use proton_drive_rs::{MemberRole, ProtonDriveClient}; +use proton_sdk::ids::NodeUid; + +/// Trash then permanently delete the given node; best-effort, logs on failure. +async fn cleanup(client: &ProtonDriveClient, uid: &NodeUid) { + if let Err(e) = client.trash_nodes(std::slice::from_ref(uid)).await { + eprintln!("[cleanup] trash failed: {e}"); + return; + } + if let Err(e) = client.delete_nodes(std::slice::from_ref(uid)).await { + eprintln!("[cleanup] delete failed: {e}"); + } +} + +/// Create a throwaway folder under My Files, returning its uid. +async fn scratch_folder(client: &ProtonDriveClient, tag: &str) -> NodeUid { + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + let name = format!("{tag}-{}", common::unique_suffix()); + client + .create_folder(&root.uid, &name, None) + .await + .expect("create scratch folder") +} + +// --------------------------------------------------------------------------- +// Public links +// --------------------------------------------------------------------------- + +/// Full public-link lifecycle on a folder: create → get → remove. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn public_link_create_get_remove() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "publink").await; + + // No public link yet. + assert!( + client + .get_public_link(&folder) + .await + .expect("get_public_link (before)") + .is_none(), + "a fresh folder must have no public link" + ); + + // Create a viewer link (no custom password, no expiry). + let link = client + .create_public_link(&folder, MemberRole::Viewer, None, None) + .await + .expect("create_public_link"); + + assert_eq!(link.role, MemberRole::Viewer, "role must round-trip"); + assert!( + !link.has_custom_password, + "no custom password was requested" + ); + let url = link.url.as_deref().expect("created link carries its URL"); + assert!( + url.contains('#'), + "the URL must carry the secret password fragment: {url}" + ); + + // Now it must be discoverable via get_public_link. + let fetched = client + .get_public_link(&folder) + .await + .expect("get_public_link (after)") + .expect("public link must exist after creation"); + assert_eq!( + fetched.public_link_id, link.public_link_id, + "get must return the link we just created" + ); + assert_eq!(fetched.role, MemberRole::Viewer, "fetched role must match"); + + // Remove it and confirm it is gone. + client + .remove_public_link(&link) + .await + .expect("remove_public_link"); + assert!( + client + .get_public_link(&folder) + .await + .expect("get_public_link (after remove)") + .is_none(), + "public link must be gone after removal" + ); + + cleanup(client, &folder).await; +} + +/// A public link with a custom password reports `has_custom_password` and still +/// round-trips through get/remove. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn public_link_with_custom_password() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "publink-pw").await; + + let link = client + .create_public_link(&folder, MemberRole::Editor, Some("s3cr3t!"), None) + .await + .expect("create_public_link with custom password"); + + assert_eq!(link.role, MemberRole::Editor, "editor role must round-trip"); + assert!( + link.has_custom_password, + "a custom password must set has_custom_password" + ); + + let fetched = client + .get_public_link(&folder) + .await + .expect("get_public_link") + .expect("link must exist"); + assert!( + fetched.has_custom_password, + "the listed link must also report the custom password" + ); + + client + .remove_public_link(&link) + .await + .expect("remove_public_link"); + + cleanup(client, &folder).await; +} + +// --------------------------------------------------------------------------- +// Standard share creation (no real invitees) +// --------------------------------------------------------------------------- + +/// `share_node` with an empty invitee list provisions the standard share (the +/// crypto + `POST shares`) without emailing anyone; the members and pending +/// invitations then read back empty. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn share_node_creates_share_and_lists_are_empty() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "share").await; + + // Not shared yet: both listings are empty. + assert!( + client + .list_share_members(&folder) + .await + .expect("list_share_members (before)") + .is_empty(), + "an unshared folder has no members" + ); + assert!( + client + .list_share_invitations(&folder) + .await + .expect("list_share_invitations (before)") + .is_empty(), + "an unshared folder has no invitations" + ); + + // Provision the share without inviting anyone. + let created = client + .share_node(&folder, &[], None) + .await + .expect("share_node with no invitees"); + assert!( + created.is_empty(), + "no invitees means no invitations created: {created:?}" + ); + + // The share now exists, but with no members and no pending invitations. + assert!( + client + .list_share_members(&folder) + .await + .expect("list_share_members (after)") + .is_empty(), + "no one was invited, so there are no members" + ); + assert!( + client + .list_share_invitations(&folder) + .await + .expect("list_share_invitations (after)") + .is_empty(), + "no one was invited, so there are no pending invitations" + ); + + cleanup(client, &folder).await; +} + +// --------------------------------------------------------------------------- +// Read-only shared-with-me / incoming invitations +// --------------------------------------------------------------------------- + +/// The shared-with-me and incoming-invitation read surfaces must resolve without +/// error even when there is nothing shared with this account (they typically +/// return empty on the throwaway test account). +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn shared_with_me_and_incoming_invitations_read() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let shared = client + .enumerate_shared_with_me_node_uids() + .await + .expect("enumerate_shared_with_me_node_uids must not error"); + eprintln!("[info] shared-with-me count: {}", shared.len()); + + let incoming = client + .list_incoming_invitations() + .await + .expect("list_incoming_invitations must not error"); + eprintln!("[info] incoming invitations count: {}", incoming.len()); +} diff --git a/crates/proton-sdk/Cargo.toml b/crates/proton-sdk/Cargo.toml index 72efedf..ce25e54 100644 --- a/crates/proton-sdk/Cargo.toml +++ b/crates/proton-sdk/Cargo.toml @@ -37,6 +37,7 @@ num-bigint.workspace = true num-traits.workspace = true getrandom.workspace = true rand.workspace = true +rand_08.workspace = true [dev-dependencies] # A one-shot loopback HTTP server backs the telemetry-instrumentation test; diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs index d99b472..c426013 100644 --- a/crates/proton-sdk/src/account.rs +++ b/crates/proton-sdk/src/account.rs @@ -43,6 +43,31 @@ pub struct Address { pub primary_key_id: AddressKeyId, } +/// Total account storage usage, in bytes (all Proton products, not Drive-only). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Quota { + /// Total storage available to the account. + pub max_space: i64, + /// Storage currently used. + pub used_space: i64, +} + +impl Quota { + /// Bytes still free (never negative). + pub fn available(&self) -> i64 { + (self.max_space - self.used_space).max(0) + } + + /// Fraction of the quota in use, `0.0..=1.0` (0 when `max_space` is 0). + pub fn used_fraction(&self) -> f64 { + if self.max_space <= 0 { + 0.0 + } else { + (self.used_space as f64 / self.max_space as f64).clamp(0.0, 1.0) + } + } +} + /// Resolves account keys needed to decrypt Drive metadata. /// /// Construction takes the mailbox (data) password because, per Proton's key @@ -238,6 +263,18 @@ impl AccountClient { keys } + /// Total account storage usage (`core/v4/users`). + /// + /// Account-wide `MaxSpace`/`UsedSpace` across all Proton products, not the + /// Drive-only quota. Not cached — reflects live usage on each call. + pub async fn quota(&self) -> Result { + let response: UserResponse = self.inner.http.get("core/v4/users").await?; + Ok(Quota { + max_space: response.user.max_space, + used_space: response.user.used_space, + }) + } + /// Decrypted user (account) keys. async fn user_keys(&self) -> Result> { { diff --git a/crates/proton-sdk/src/account/dtos.rs b/crates/proton-sdk/src/account/dtos.rs index ba92e49..cc4e192 100644 --- a/crates/proton-sdk/src/account/dtos.rs +++ b/crates/proton-sdk/src/account/dtos.rs @@ -20,6 +20,12 @@ pub(super) struct UserResponse { pub(super) struct UserDto { #[serde(rename = "ID")] pub id: UserId, + /// Total account storage in bytes (all Proton products). + #[serde(rename = "MaxSpace")] + pub max_space: i64, + /// Used account storage in bytes. + #[serde(rename = "UsedSpace")] + pub used_space: i64, #[serde(rename = "Keys")] pub keys: Vec, } diff --git a/crates/proton-sdk/src/cache.rs b/crates/proton-sdk/src/cache.rs index 8a2a44a..6ced21f 100644 --- a/crates/proton-sdk/src/cache.rs +++ b/crates/proton-sdk/src/cache.rs @@ -232,7 +232,7 @@ impl EncryptedCacheRepository { fn encrypt(&self, entry_key: &str, plaintext: &str) -> Result { let mut salt = [0u8; SALT_LEN]; - getrandom::getrandom(&mut salt) + getrandom::fill(&mut salt) .map_err(|e| ProtonError::invalid_operation(format!("cache salt: {e}")))?; let (key, nonce) = self.derive(&salt, entry_key)?; let cipher = Aes256Gcm::new_from_slice(&key) @@ -241,7 +241,7 @@ impl EncryptedCacheRepository { // `ciphertext || tag` — matching the C# `[salt][ciphertext][tag]` layout. let sealed = cipher .encrypt( - Nonce::from_slice(&nonce), + &Nonce::from(nonce), Payload { msg: plaintext.as_bytes(), aad: &[], @@ -268,7 +268,7 @@ impl EncryptedCacheRepository { let cipher = Aes256Gcm::new_from_slice(&key) .map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?; match cipher.decrypt( - Nonce::from_slice(&nonce), + &Nonce::from(nonce), Payload { msg: sealed, aad: &[], diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index 8c0c2e0..7ae4111 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -17,12 +17,14 @@ mod verify; pub use content::ContentKey; pub use derive::derive_key_passphrase; pub use encrypt::{ - GeneratedNodeKey, VolumeCreationMaterial, build_volume_creation_material, - generate_node_hash_key, generate_node_key, generate_node_key_aead, + GeneratedNodeKey, SHARING_EXTERNAL_INVITATION_CONTEXT, SHARING_INVITER_CONTEXT, + SHARING_MEMBER_CONTEXT, StandardShareMaterial, VolumeCreationMaterial, accept_invitation, + build_standard_share_material, build_volume_creation_material, encrypt_external_invitation, + encrypt_invitation, generate_node_hash_key, generate_node_key, generate_node_key_aead, }; pub use errors::CryptoError; pub use keys::{PrivateKey, decrypt_armored_with_keys}; -pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, generate_proofs}; +pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier}; pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached}; /// Result alias for crypto operations. diff --git a/crates/proton-sdk/src/crypto/content.rs b/crates/proton-sdk/src/crypto/content.rs index 21fc13c..83d6fc8 100644 --- a/crates/proton-sdk/src/crypto/content.rs +++ b/crates/proton-sdk/src/crypto/content.rs @@ -15,13 +15,16 @@ use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm; use pgp::packet::{ LiteralData, Packet, PacketParser, PacketTrait, PublicKeyEncryptedSessionKey, - SymEncryptedProtectedData, + SymEncryptedProtectedData, SymKeyEncryptedSessionKey, +}; +use pgp::types::{ + DecryptionKey, EncryptionKey, EskType, Password, PkeskVersion, Seipdv1ReadMode, StringToKey, }; -use pgp::types::{DecryptionKey, EncryptionKey, EskType, PkeskVersion, Seipdv1ReadMode}; -use super::encrypt::{RecipientOp, recipient_encryption_key}; +use super::encrypt::{RecipientOp, recipient_encryption_key, recipient_encryption_key_public}; use super::errors::CryptoError; use super::keys::PrivateKey; +use super::verify::PublicKey; /// The AEAD algorithm Proton uses for SEIPDv2 content blocks: AES-256-GCM. /// Encrypted v6 session keys carry no algorithm info, so it is fixed here (the @@ -47,7 +50,7 @@ impl ContentKey { /// /// Mirrors C# `PgpSessionKey.Generate()`. pub fn generate() -> Self { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let sym_alg = SymmetricKeyAlgorithm::AES256; let key = sym_alg.new_session_key(&mut rng); ContentKey { @@ -61,7 +64,7 @@ impl ContentKey { /// content key packet as a v6 PKESK. Mirrors C# /// `PgpSessionKey.GenerateForAead()`. pub fn generate_aead() -> Self { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let key = SymmetricKeyAlgorithm::AES256.new_session_key(&mut rng); ContentKey { session_key: PlainSessionKey::V6 { key }, @@ -106,7 +109,7 @@ impl ContentKey { signer: &PrivateKey, plaintext: &[u8], ) -> Result, CryptoError> { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); // Build a signed (but unencrypted) message: one-pass signature, literal // data, then the signature packet. `seipd_*` would generate its own @@ -131,7 +134,7 @@ impl ContentKey { /// [`encrypt_thumbnail`](Self::encrypt_thumbnail). fn seal_seipd(&self, inner: &[u8]) -> Result, CryptoError> { let (sym_alg, key) = self.session_parts()?; - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let seipd = if self.is_aead() { SymEncryptedProtectedData::encrypt_seipdv2( @@ -175,6 +178,146 @@ impl ContentKey { Ok(out) } + /// Encrypt this session key to a recipient **public** key as a PKESK. + /// + /// Used for the sharing invitation `KeyPacket`: the share's passphrase + /// session key delivered to an invitee whose public key we resolved via + /// `core/v4/keys/all`. Mirrors JS `encryptSessionKey(shareSessionKey, + /// inviteePublicKey)`. + pub fn to_packet_for_public(&self, pubkey: &PublicKey) -> Result, CryptoError> { + let (sym_alg, key) = self.session_parts()?; + + let packet = if self.is_aead() { + recipient_encryption_key_public(pubkey.signed(), ContentKeyPacketV6Op { key })? + } else { + recipient_encryption_key_public(pubkey.signed(), ContentKeyPacketOp { sym_alg, key })? + }; + + let mut out = Vec::new(); + packet + .to_writer_with_header(&mut out) + .map_err(|e| CryptoError::Encrypt(format!("serialize session key packet: {e}")))?; + Ok(out) + } + + /// Encrypt `data` to one or more recipient keys under this session key, + /// returning an armored PGP message: a PKESK per recipient followed by the + /// SEIPD body. No inline signature — Proton signs share/node passphrases with + /// a *detached* signature carried separately. + /// + /// Used to build a standard share's `SharePassphrase`, which must be + /// decryptable by both the node key and the owning address key (JS + /// `generateKey([nodeKey, addressKey], addressKey)`), while keeping this + /// session key so it can later be handed to invitees. + pub fn encrypt_message_to( + &self, + recipients: &[&PrivateKey], + data: &[u8], + ) -> Result { + if recipients.is_empty() { + return Err(CryptoError::Encrypt( + "encrypt_message_to needs at least one recipient".into(), + )); + } + + let mut message = Vec::new(); + for recipient in recipients { + message.extend_from_slice(&self.to_packet(recipient)?); + } + message.extend_from_slice(&self.encrypt_block(data)?); + + let mut out = Vec::new(); + pgp::armor::write( + &RawPackets(&message), + pgp::armor::BlockType::Message, + &mut out, + None, + true, + ) + .map_err(|e| CryptoError::Encrypt(format!("armor message: {e}")))?; + String::from_utf8(out) + .map_err(|e| CryptoError::Encrypt(format!("armored message is not utf8: {e}"))) + } + + /// Encrypt and inline-sign `data` as **text** under *this* session key, + /// wrapped to `recipient`, returning an armored PGP message (PKESK + SEIPD). + /// + /// Unlike [`PrivateKey::encrypt_and_sign`](crate::crypto::PrivateKey::encrypt_and_sign), + /// which mints a fresh session key, this reuses the session key already held + /// here. Renaming a device's root folder needs exactly this: Proton's rename + /// endpoint keeps the link's existing name *key packet* and replaces only the + /// *data packet*, so the new name text must be encrypted under the **same** + /// session key as the current name — otherwise the retained key packet can no + /// longer decrypt the data packet (MDC failure). Mirrors C# + /// `DeviceCrypto.GetRenameRequest` / + /// `PgpEncrypter.EncryptAndSignText(name, EncryptionSecrets(shareKey, nameSessionKey), signingKey)`, + /// where `nameSessionKey` is recovered from the current name via + /// [`PrivateKey::recover_message_session_key`](crate::crypto::PrivateKey::recover_message_session_key). + pub fn encrypt_and_sign_text_to( + &self, + recipient: &PrivateKey, + signer: &PrivateKey, + data: &[u8], + ) -> Result { + let mut rng = rand_08::thread_rng(); + + // Build a signed (but unencrypted) *text* message — one-pass signature, + // text literal, then the signature packet — then seal it under this + // session key (as [`encrypt_thumbnail`](Self::encrypt_thumbnail) does). + let mut builder = MessageBuilder::from_bytes(bytes::Bytes::new(), data.to_vec()); + builder.sign_text(); + builder.sign( + &signer.key().primary_key, + signer.password(), + HashAlgorithm::Sha256, + ); + let inner = builder + .to_vec(&mut rng) + .map_err(|e| CryptoError::Encrypt(format!("sign name: {e}")))?; + + let mut message = self.to_packet(recipient)?; + message.extend_from_slice(&self.seal_seipd(&inner)?); + + let mut out = Vec::new(); + pgp::armor::write( + &RawPackets(&message), + pgp::armor::BlockType::Message, + &mut out, + None, + true, + ) + .map_err(|e| CryptoError::Encrypt(format!("armor message: {e}")))?; + String::from_utf8(out) + .map_err(|e| CryptoError::Encrypt(format!("armored message is not utf8: {e}"))) + } + + /// Encrypt this session key **symmetrically** under a password as a v4 SKESK + /// packet, returning the raw packet bytes. + /// + /// Used for a public share link's `SharePassphraseKeyPacket`: the share + /// session key wrapped with the SRP-salted passphrase so anyone with the link + /// password can open it. The S2K (salt + iteration count) travels inside the + /// packet, so the recipient re-derives the key from the same password. Mirrors + /// JS `encryptSessionKeyWithPassword`. + pub fn encrypt_with_password(&self, password: &[u8]) -> Result, CryptoError> { + let (sym_alg, key) = self.session_parts()?; + let mut rng = rand_08::thread_rng(); + let s2k = StringToKey::new_default(&mut rng); + let skesk = SymKeyEncryptedSessionKey::encrypt_v4( + &Password::from(password), + &RawSessionKey::from(key), + s2k, + sym_alg, + ) + .map_err(|e| CryptoError::Encrypt(format!("skesk encrypt: {e}")))?; + + let mut out = Vec::new(); + skesk + .to_writer_with_header(&mut out) + .map_err(|e| CryptoError::Encrypt(format!("serialize skesk: {e}")))?; + Ok(out) + } + /// The raw symmetric key bytes of this content key. /// /// Mirrors C# `PgpSessionKey.Export()`; used to produce the file's @@ -350,6 +493,23 @@ impl PrivateKey { /// parent → new parent), so the locked node key still unlocks and the node's /// `NodePassphraseSignature` need not be reissued. Mirrors C# move's /// `destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(passphrase))`. + /// Recover the session key of an armored PGP message addressed to this key. + /// + /// De-armors the message and decrypts its leading PKESK to recover the + /// symmetric session key (the trailing SEIPD is ignored). Used when creating + /// a share to lift a node's passphrase/name session keys so they can be + /// re-encrypted to the share key (JS `getNodePrivateAndSessionKeys`). + pub fn recover_message_session_key( + &self, + armored_message: &str, + ) -> Result { + let mut raw = Vec::new(); + let mut dearmor = Dearmor::new(Cursor::new(armored_message.as_bytes())); + std::io::Read::read_to_end(&mut dearmor, &mut raw) + .map_err(|e| CryptoError::Parse(format!("de-armor message: {e}")))?; + self.decrypt_content_key(&raw) + } + pub fn rewrap_message_to( &self, armored_message: &str, @@ -430,7 +590,7 @@ impl RecipientOp for ContentKeyPacketOp<'_> { type Out = PublicKeyEncryptedSessionKey; fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let session_key = RawSessionKey::from(self.key); PublicKeyEncryptedSessionKey::from_session_key_v3( &mut rng, @@ -451,7 +611,7 @@ impl RecipientOp for ContentKeyPacketV6Op<'_> { type Out = PublicKeyEncryptedSessionKey; fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let session_key = RawSessionKey::from(self.key); PublicKeyEncryptedSessionKey::from_session_key_v6(&mut rng, &session_key, pubkey) } @@ -468,7 +628,7 @@ mod tests { /// Generate a passphrase-locked RSA key able to both sign and encrypt — a /// stand-in for a Proton node key (single primary, no subkey). fn generate_node_key(passphrase: &str) -> SignedSecretKey { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let params = SecretKeyParamsBuilder::default() .key_type(KeyType::Rsa(2048)) .can_sign(true) @@ -483,7 +643,7 @@ mod tests { #[test] fn content_key_packet_and_block_round_trip() { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let signed = generate_node_key("pw"); let armored = signed .to_armored_string(None.into()) @@ -513,6 +673,42 @@ mod tests { assert_eq!(decrypted, plaintext); } + #[test] + fn content_key_password_skesk_round_trip() { + // Public-link path: wrap the share session key under a password as a + // SKESK, then re-derive the key from the same password (via the packet's + // own S2K) and recover the session key. + let content_key = ContentKey::generate(); + let password = b"the-link-password"; + + let skesk_bytes = content_key + .encrypt_with_password(password) + .expect("skesk encrypt"); + + // The bytes are a bare SKESK (no data packet); parse the packet directly. + let mut parser = PacketParser::new(Cursor::new(&skesk_bytes)); + let packet = parser.next().expect("a packet").expect("valid packet"); + let Packet::SymKeyEncryptedSessionKey(skesk) = packet else { + panic!("expected a SKESK packet"); + }; + + // Re-derive the symmetric key from the password using the packet's S2K, + // then decrypt the wrapped session key. + let s2k = skesk.s2k().expect("skesk has s2k"); + let alg = SymmetricKeyAlgorithm::AES256; + let derived = s2k + .derive_key(&password[..], alg.key_size()) + .expect("derive key"); + let recovered = skesk.decrypt(derived).expect("decrypt skesk"); + + match &recovered { + PlainSessionKey::V3_4 { key, .. } => { + assert_eq!(key.as_ref(), content_key.export().unwrap()); + } + other => panic!("unexpected session key variant: {other:?}"), + } + } + #[test] fn generated_content_key_round_trip() { // Upload path: generate a content key, seal it to the node key as a @@ -559,6 +755,85 @@ mod tests { assert_eq!(recovered.decrypt_block(&block).expect("decrypt"), plaintext); } + #[test] + fn rename_reusing_session_key_survives_key_packet_retention() { + // Device rename: Proton keeps the link's existing name *key packet* (PKESK) + // and swaps only its *data packet* (SEIPD). Reproduce that here — encrypt a + // fresh name to the share key, recover its session key, encrypt a new name + // under that same session key, then reassemble the message as the server + // would (original key packet + new data packet) and confirm it decrypts. + let share = super::super::encrypt::generate_node_key().expect("generate share key"); + let signer = super::super::encrypt::generate_node_key().expect("generate signer"); + + // The original name, encrypted to the share key with a fresh session key. + let original = share + .key + .encrypt_and_sign(&signer.key, b"my-device", true, false) + .expect("encrypt original name"); + assert_eq!( + share + .key + .decrypt_armored_message(&original) + .expect("decrypt original"), + b"my-device" + ); + + // Recover that session key and re-encrypt the new name under it. + let name_session_key = share + .key + .recover_message_session_key(&original) + .expect("recover name session key"); + let renamed = name_session_key + .encrypt_and_sign_text_to(&share.key, &signer.key, b"my-device-renamed") + .expect("encrypt renamed"); + + // A naive re-encrypt would round-trip on its own, but the server discards + // our key packet. Reassemble original-PKESK + renamed-SEIPD and decrypt. + let original_pkesk = { + let mut raw = Vec::new(); + let mut dearmor = Dearmor::new(Cursor::new(original.as_bytes())); + std::io::Read::read_to_end(&mut dearmor, &mut raw).expect("de-armor original"); + let mut parser = PacketParser::new(Cursor::new(&raw)); + let packet = parser.next().expect("a packet").expect("valid packet"); + let Packet::PublicKeyEncryptedSessionKey(pkesk) = packet else { + panic!("expected a leading PKESK"); + }; + let mut out = Vec::new(); + pkesk + .to_writer_with_header(&mut out) + .expect("serialize pkesk"); + out + }; + let renamed_seipd = { + let mut raw = Vec::new(); + let mut dearmor = Dearmor::new(Cursor::new(renamed.as_bytes())); + std::io::Read::read_to_end(&mut dearmor, &mut raw).expect("de-armor renamed"); + serialize_seipd(&raw).expect("serialize renamed seipd") + }; + + let mut server_stored = original_pkesk; + server_stored.extend_from_slice(&renamed_seipd); + let mut armored = Vec::new(); + pgp::armor::write( + &RawPackets(&server_stored), + pgp::armor::BlockType::Message, + &mut armored, + None, + true, + ) + .expect("armor server-stored message"); + let armored = String::from_utf8(armored).expect("utf8"); + + assert_eq!( + share + .key + .decrypt_armored_message(&armored) + .expect("decrypt server-reassembled renamed name"), + b"my-device-renamed", + "reusing the recovered session key must keep the retained key packet valid" + ); + } + #[test] fn thumbnail_encrypt_inline_signed_round_trip() { // A thumbnail block is encrypt-and-inline-signed under the content key. @@ -698,7 +973,7 @@ mod tests { #[test] fn rejects_non_pkesk_content_key_packet() { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let key = PrivateKey::from_armored( &generate_node_key("pw") .to_armored_string(None.into()) diff --git a/crates/proton-sdk/src/crypto/encrypt.rs b/crates/proton-sdk/src/crypto/encrypt.rs index 00209ce..635c613 100644 --- a/crates/proton-sdk/src/crypto/encrypt.rs +++ b/crates/proton-sdk/src/crypto/encrypt.rs @@ -11,13 +11,13 @@ use base64::engine::general_purpose::STANDARD as BASE64; use bytes::Bytes; use pgp::composed::{ ArmorOptions, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder, - SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder, + SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey, SubkeyParamsBuilder, }; use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm; -use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData}; +use pgp::packet::{Notation, SignatureConfig, SignatureType, Subpacket, SubpacketData}; use pgp::types::{CompressionAlgorithm, EncryptionKey, KeyDetails, KeyVersion}; -use rand::RngCore; +use rand_08::RngCore; use super::errors::CryptoError; use super::keys::PrivateKey; @@ -64,6 +64,33 @@ pub(crate) fn recipient_encryption_key( )) } +/// Like [`recipient_encryption_key`] but for a recipient we only hold the +/// **public** key of (e.g. a sharing invitee resolved via `core/v4/keys/all`). +/// +/// `EncryptionKey for SignedPublicKey` encrypts to the *primary* key, but Proton +/// keys have a signing primary and a separate encryption subkey, so we must pick +/// the encryption-capable (sub)key ourselves — same rule as the secret path. +pub(crate) fn recipient_encryption_key_public( + key: &SignedPublicKey, + op: Op, +) -> Result { + if key.primary_key.algorithm().can_encrypt() { + return op + .run(&key.primary_key) + .map_err(|e| CryptoError::Encrypt(format!("encrypt to primary key: {e}"))); + } + for sub in &key.public_subkeys { + if sub.key.algorithm().can_encrypt() { + return op + .run(&sub.key) + .map_err(|e| CryptoError::Encrypt(format!("encrypt to subkey: {e}"))); + } + } + Err(CryptoError::Encrypt( + "public key has no encryption-capable (sub)key".into(), + )) +} + /// A freshly generated, passphrase-locked node key. pub struct GeneratedNodeKey { /// The unlocked node key, ready to sign and decrypt. @@ -99,7 +126,7 @@ pub fn generate_node_key_aead() -> Result { /// Generate a node key (Ed25519 primary + X25519 encryption subkey) at the given /// OpenPGP key version. v4 for legacy files/folders, v6 for AEAD files. fn generate_node_key_versioned(version: KeyVersion) -> Result { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let passphrase = generate_passphrase(); let pw_string = String::from_utf8(passphrase.clone()) .map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?; @@ -144,7 +171,7 @@ fn generate_node_key_versioned(version: KeyVersion) -> Result Vec { let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); + rand_08::thread_rng().fill_bytes(&mut bytes); BASE64.encode(bytes).into_bytes() } @@ -158,7 +185,7 @@ fn generate_passphrase() -> Vec { /// signer) with `CryptoGenerator.GenerateFolderHashKey` (32 bytes). pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result { let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); + rand_08::thread_rng().fill_bytes(&mut bytes); encrypt_and_sign(node_key.key(), Some(node_key), &bytes, false, false) } @@ -216,7 +243,7 @@ pub fn build_volume_creation_material( let folder_passphrase_signature = address_key.sign_detached(&folder.passphrase)?; let mut hash_key = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut hash_key); + rand_08::thread_rng().fill_bytes(&mut hash_key); let folder_hash_key = folder .key .encrypt_and_sign(address_key, &hash_key, false, false)?; @@ -257,7 +284,7 @@ pub(crate) fn encrypt_and_sign( /// Produce an armored detached signature over `data` by `signer`'s primary /// (signing) key. Mirrors C# `PgpPrivateKey.Sign` / `SignDetached`. pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let key = &signer.key().primary_key; let pw = signer.password(); @@ -293,6 +320,212 @@ pub(crate) fn sign_detached(signer: &PrivateKey, data: &[u8]) -> Result Result, CryptoError> { + let mut rng = rand_08::thread_rng(); + let key = &signer.key().primary_key; + let pw = signer.password(); + + let mut config = SignatureConfig::from_key(&mut rng, key, SignatureType::Binary) + .map_err(|e| CryptoError::Encrypt(format!("signature config: {e}")))?; + + // Same subpacket stamping as `sign_detached` (issuer fingerprint + creation + // time, else GopenPGP rejects the signature) plus the critical context + // notation the server verifies against. + config.hashed_subpackets = vec![ + Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint())) + .map_err(|e| CryptoError::Encrypt(format!("issuer fingerprint subpacket: {e}")))?, + Subpacket::regular(SubpacketData::SignatureCreationTime( + pgp::types::Timestamp::now(), + )) + .map_err(|e| CryptoError::Encrypt(format!("creation-time subpacket: {e}")))?, + Subpacket::critical(SubpacketData::Notation(Notation { + readable: true, + name: Bytes::from_static(SIGNATURE_CONTEXT_NOTATION), + value: Bytes::copy_from_slice(context.as_bytes()), + })) + .map_err(|e| CryptoError::Encrypt(format!("context notation subpacket: {e}")))?, + ]; + if key.version() <= KeyVersion::V4 { + config.unhashed_subpackets = vec![ + Subpacket::regular(SubpacketData::IssuerKeyId(key.legacy_key_id())) + .map_err(|e| CryptoError::Encrypt(format!("issuer subpacket: {e}")))?, + ]; + } + + let signature = config + .sign(key, &pw, data) + .map_err(|e| CryptoError::Encrypt(format!("detached sign: {e}")))?; + + let mut bytes = Vec::new(); + pgp::ser::Serialize::to_writer(&DetachedSignature::new(signature), &mut bytes) + .map_err(|e| CryptoError::Encrypt(format!("serialize signature: {e}")))?; + Ok(bytes) +} + +/// GopenPGP signing context for the inviter's signature over a sharing +/// invitation key packet (`drive.share-member.inviter`). +pub const SHARING_INVITER_CONTEXT: &str = "drive.share-member.inviter"; +/// GopenPGP signing context for an accepting member's signature over the share +/// session key (`drive.share-member.member`). +pub const SHARING_MEMBER_CONTEXT: &str = "drive.share-member.member"; +/// GopenPGP signing context for the inviter's signature over an external +/// (non-Proton) invitation (`drive.share-member.external-invitation`). +pub const SHARING_EXTERNAL_INVITATION_CONTEXT: &str = "drive.share-member.external-invitation"; + +/// The locked share key plus the crypto payload the `volumes/{vid}/shares` +/// endpoint needs to create a standard (shared) share on a node. +/// +/// Mirrors JS `SharingCryptoService.generateShareKeys` output. The `share_key` +/// and `share_session_key` are kept (not sent) so invitations can encrypt the +/// share session key to invitees right after the share is created. +pub struct StandardShareMaterial { + /// Locked share secret key (armored) — `ShareKey`. + pub share_key_armored: String, + /// Share passphrase encrypted to `[node_key, address_key]` — `SharePassphrase`. + pub share_passphrase: String, + /// Detached signature over the share passphrase by the address key. + pub share_passphrase_signature: String, + /// base64 PKESK: the node's passphrase session key re-encrypted to the share + /// key — `PassphraseKeyPacket` (lets the share decrypt the node passphrase). + pub passphrase_key_packet: String, + /// base64 PKESK: the node's name session key re-encrypted to the share key — + /// `NameKeyPacket`. + pub name_key_packet: String, + /// The unlocked share key (not sent; kept for follow-up crypto). + pub share_key: PrivateKey, + /// The share passphrase session key (not sent; handed to invitees). + pub share_session_key: super::content::ContentKey, +} + +/// Build the crypto material for a standard share over a node. +/// +/// Mirrors JS `generateShareKeys(nodeKeys, addressKey)`: +/// - a fresh share key is generated and locked with a random passphrase; +/// - that passphrase is encrypted (via a fresh session key) to **both** the node +/// key and the address key, and detached-signed by the address key — so any +/// admin holding the node key, and the owner, can unwrap the share; +/// - the node's own passphrase and name session keys are re-encrypted to the new +/// share key, so the share grants access to the node's passphrase and name. +pub fn build_standard_share_material( + node_key: &PrivateKey, + node_passphrase_session_key: &super::content::ContentKey, + node_name_session_key: &super::content::ContentKey, + address_key: &PrivateKey, +) -> Result { + let generated = generate_node_key()?; + + let share_session_key = super::content::ContentKey::generate(); + let share_passphrase = + share_session_key.encrypt_message_to(&[node_key, address_key], &generated.passphrase)?; + let share_passphrase_signature = address_key.sign_detached(&generated.passphrase)?; + + let passphrase_key_packet = + BASE64.encode(node_passphrase_session_key.to_packet(&generated.key)?); + let name_key_packet = BASE64.encode(node_name_session_key.to_packet(&generated.key)?); + + Ok(StandardShareMaterial { + share_key_armored: generated.locked_armored, + share_passphrase, + share_passphrase_signature, + passphrase_key_packet, + name_key_packet, + share_key: generated.key, + share_session_key, + }) +} + +/// Encrypt a sharing invitation for a Proton user. +/// +/// The share's passphrase session key is encrypted to the invitee's public key +/// (`KeyPacket`) and the packet bytes are detached-signed by the inviter's +/// address key under the `drive.share-member.inviter` context +/// (`KeyPacketSignature`). Both are returned base64-encoded. Mirrors JS +/// `encryptInvitation`. +pub fn encrypt_invitation( + share_session_key: &super::content::ContentKey, + inviter_key: &PrivateKey, + invitee_public_key: &super::verify::PublicKey, +) -> Result<(String, String), CryptoError> { + let key_packet = share_session_key.to_packet_for_public(invitee_public_key)?; + let signature = + sign_detached_binary_with_context(inviter_key, &key_packet, SHARING_INVITER_CONTEXT)?; + Ok((BASE64.encode(&key_packet), BASE64.encode(&signature))) +} + +/// Sign an accepted invitation's share session key. +/// +/// The invitation's `KeyPacket` (base64 PKESK addressed to the invitee) is +/// decrypted with the invitee's address keys to recover the share session key, +/// whose raw bytes are detached-signed under the `drive.share-member.member` +/// context by the invitee's key. Returns the base64 signature the +/// `.../accept` endpoint expects as `SessionKeySignature`. Mirrors JS +/// `acceptInvitation`. +pub fn accept_invitation( + base64_key_packet: &str, + invitee_keys: &[PrivateKey], + signing_key: &PrivateKey, +) -> Result { + let key_packet = BASE64 + .decode(base64_key_packet) + .map_err(|e| CryptoError::Parse(format!("invitation key packet base64: {e}")))?; + + let session_key = invitee_keys + .iter() + .find_map(|k| k.decrypt_content_key(&key_packet).ok()) + .ok_or_else(|| { + CryptoError::Decrypt("no invitee key could decrypt the invitation key packet".into()) + })?; + + let signature = sign_detached_binary_with_context( + signing_key, + &session_key.export()?, + SHARING_MEMBER_CONTEXT, + )?; + Ok(BASE64.encode(&signature)) +} + +/// Sign an external (non-Proton) invitation. +/// +/// The invitee has no Proton key to encrypt the share session key to, so instead +/// the inviter signs the payload `"{invitee_email}|{base64(share session key)}"` +/// under the `drive.share-member.external-invitation` context. When the invitee +/// later signs up, the server can bind the pending invitation to their new key. +/// Returns the base64 signature the external-invitations endpoint expects. +/// Mirrors JS `encryptExternalInvitation`. +pub fn encrypt_external_invitation( + share_session_key: &super::content::ContentKey, + inviter_key: &PrivateKey, + invitee_email: &str, +) -> Result { + let payload = format!( + "{invitee_email}|{}", + BASE64.encode(share_session_key.export()?) + ); + let signature = sign_detached_binary_with_context( + inviter_key, + payload.as_bytes(), + SHARING_EXTERNAL_INVITATION_CONTEXT, + )?; + Ok(BASE64.encode(&signature)) +} + /// Inline encrypt-and-sign against a selected recipient public key. struct EncryptSignOp<'a> { data: Vec, @@ -305,7 +538,7 @@ impl RecipientOp for EncryptSignOp<'_> { type Out = String; fn run(self, pubkey: &impl EncryptionKey) -> pgp::errors::Result { - let mut rng = rand::thread_rng(); + let mut rng = rand_08::thread_rng(); let mut builder = MessageBuilder::from_bytes(Bytes::new(), self.data) .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); @@ -439,4 +672,155 @@ mod tests { .expect("decrypt hash key"); assert_eq!(hash_key.len(), 32); } + + #[test] + fn standard_share_material_round_trips() { + use super::super::content::ContentKey; + + // Stand-ins for the node key, the owning address key, and the node's own + // passphrase + name session keys. + let node = generate_node_key().expect("generate node key"); + let address = generate_node_key().expect("generate address key"); + let node_passphrase_sk = ContentKey::generate(); + let node_name_sk = ContentKey::generate(); + + let material = build_standard_share_material( + &node.key, + &node_passphrase_sk, + &node_name_sk, + &address.key, + ) + .expect("build share material"); + + // The share passphrase must be decryptable by BOTH the node key and the + // address key, and its detached signature verify against the address key. + let via_node = node + .key + .decrypt_armored_message(&material.share_passphrase) + .expect("decrypt share passphrase via node key"); + let via_address = address + .key + .decrypt_armored_message(&material.share_passphrase) + .expect("decrypt share passphrase via address key"); + assert_eq!(via_node, via_address); + address + .key + .verify_detached_signature(&material.share_passphrase_signature, &via_node) + .expect("verify share passphrase signature"); + + // That passphrase unlocks the share key. + let share_key = + PrivateKey::from_armored(&material.share_key_armored, &via_node).expect("unlock share"); + + // The passphrase/name key packets re-deliver the node's session keys to + // the share key: decrypting them with the share key yields the originals. + let recovered_pp = share_key + .decrypt_content_key(&BASE64.decode(&material.passphrase_key_packet).unwrap()) + .expect("recover passphrase session key"); + assert_eq!( + recovered_pp.export().unwrap(), + node_passphrase_sk.export().unwrap() + ); + let recovered_name = share_key + .decrypt_content_key(&BASE64.decode(&material.name_key_packet).unwrap()) + .expect("recover name session key"); + assert_eq!( + recovered_name.export().unwrap(), + node_name_sk.export().unwrap() + ); + } + + #[test] + fn invitation_key_packet_round_trips_to_invitee() { + use super::super::content::ContentKey; + use super::super::verify::PublicKey; + use pgp::composed::Deserializable as _; + + let node = generate_node_key().expect("generate node key"); + let address = generate_node_key().expect("generate address key"); + let material = build_standard_share_material( + &node.key, + &ContentKey::generate(), + &ContentKey::generate(), + &address.key, + ) + .expect("build share material"); + + // The invitee is any Proton-shaped key; we hold only its public half. + let invitee = generate_node_key().expect("generate invitee key"); + let invitee_pub_armored = invitee + .key + .signed_public_key() + .to_armored_string(Default::default()) + .expect("armor invitee public key"); + let invitee_pub = PublicKey::from_armored(&invitee_pub_armored).expect("parse invitee pub"); + + let (key_packet_b64, signature_b64) = + encrypt_invitation(&material.share_session_key, &address.key, &invitee_pub) + .expect("encrypt invitation"); + + // The invitee recovers the share session key from the key packet. + let key_packet = BASE64.decode(&key_packet_b64).expect("decode key packet"); + let recovered = invitee + .key + .decrypt_content_key(&key_packet) + .expect("invitee decrypts key packet"); + assert_eq!( + recovered.export().unwrap(), + material.share_session_key.export().unwrap() + ); + + // The inviter's context signature over the key packet parses and verifies + // against the inviter (address) key. + let sig_bytes = BASE64.decode(&signature_b64).expect("decode signature"); + let sig = DetachedSignature::from_bytes(&sig_bytes[..]).expect("parse signature"); + sig.signature + .verify(&address.key.signed_public_key(), &key_packet[..]) + .expect("verify inviter context signature"); + } + + #[test] + fn signing_with_a_clone_does_not_corrupt_the_original_for_decryption() { + // Regression for the live device-rename failure: the account caches + // unlocked address keys and hands out `PrivateKey` *clones*. If signing + // with a clone mutated shared secret-key state (pgp interior mutability), + // a later decryption with the cached original would fail with an MDC + // error. Prove clone + sign leaves the original intact for decryption. + let address = generate_node_key().expect("generate address key"); + + // Something encrypted to the address key (stands in for a share passphrase). + let secret = b"share passphrase bytes".to_vec(); + let armored = address + .key + .encrypt(&secret) + .expect("encrypt to address key"); + + // First decryption works. + assert_eq!( + address + .key + .decrypt_armored_message(&armored) + .expect("decrypt #1"), + secret + ); + + // Sign with a *clone* of the cached key (what rename_device does). + let clone = address.key.clone(); + let _sig = clone + .sign_detached(b"device name") + .expect("sign with clone"); + let _enc = clone + .encrypt_and_sign(&clone, b"device name", true, false) + .expect("encrypt+sign with clone"); + + // The original must still decrypt afterwards. + assert_eq!( + address + .key + .decrypt_armored_message(&armored) + .expect("decrypt #2 after signing with clone"), + secret, + "signing with a clone must not corrupt the original key" + ); + } } diff --git a/crates/proton-sdk/src/crypto/srp.rs b/crates/proton-sdk/src/crypto/srp.rs index 82735e6..fc1d5c2 100644 --- a/crates/proton-sdk/src/crypto/srp.rs +++ b/crates/proton-sdk/src/crypto/srp.rs @@ -47,6 +47,45 @@ pub struct SrpProofs { pub expected_server_proof: Vec, } +/// An SRP verifier for a password, as sent when creating a Proton public share +/// link. Mirrors go-srp `GenerateVerifier` / GopenPGP `getSrpVerifier`. +pub struct SrpVerifier { + /// base64 of the random 10-byte SRP salt (`UrlPasswordSalt`). + pub salt: String, + /// base64 of the little-endian verifier bytes (`SRPVerifier`). + pub verifier: String, +} + +/// Generate an SRP verifier `v = g^x mod N` for `password`, where `g = 2`, `N` +/// is Proton's signed modulus, and `x` is the Proton password hash over a fresh +/// random salt. Used to secure a public share link with a password. +/// +/// Mirrors go-srp `GenerateVerifier`: the salt is 10 random bytes and the +/// verifier is emitted little-endian, both base64-encoded. +pub fn generate_verifier( + password: &[u8], + signed_modulus: &str, + bit_length: usize, +) -> Result { + let byte_len = bit_length / 8; + let modulus = verify_and_decode_modulus(signed_modulus)?; + + let mut salt = [0u8; 10]; + getrandom::fill(&mut salt).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?; + + let hashed_password = hash_password_v3(password, &salt, &modulus)?; + let n = from_le(&modulus); + let x = from_le(&hashed_password); + let g = BigUint::from(2u32); + let verifier = g.modpow(&x, &n); + let verifier_le = to_le_fixed(&verifier, byte_len)?; + + Ok(SrpVerifier { + salt: BASE64.encode(salt), + verifier: BASE64.encode(verifier_le), + }) +} + /// Verify the cleartext-signed modulus against Proton's key and return the raw /// (base64-decoded) modulus bytes. Mirrors go-srp `readClearSignedMessage`. fn verify_and_decode_modulus(signed_modulus: &str) -> Result, CryptoError> { @@ -174,7 +213,7 @@ fn generate_ephemeral( loop { let mut buf = vec![0u8; byte_len]; - getrandom::getrandom(&mut buf).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?; + getrandom::fill(&mut buf).map_err(|e| CryptoError::Decrypt(format!("rng: {e}")))?; let secret = from_le(&buf) % n_minus_1; if secret <= lower_bound || secret >= *n_minus_1 { continue; diff --git a/crates/proton-sdk/src/crypto/verify.rs b/crates/proton-sdk/src/crypto/verify.rs index 06d5274..2deb6de 100644 --- a/crates/proton-sdk/src/crypto/verify.rs +++ b/crates/proton-sdk/src/crypto/verify.rs @@ -55,7 +55,7 @@ impl PublicKey { Ok(Self { key }) } - fn signed(&self) -> &SignedPublicKey { + pub(crate) fn signed(&self) -> &SignedPublicKey { &self.key } } diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 11a1445..bec4804 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::Duration; -use rand::Rng; +use rand::RngExt; use reqwest::{Method, StatusCode}; use serde::Serialize; use serde::de::DeserializeOwned; @@ -573,7 +573,7 @@ fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration { if ceiling_ms == 0 { return Duration::ZERO; } - Duration::from_millis(rand::thread_rng().gen_range(0..=ceiling_ms)) + Duration::from_millis(rand::rng().random_range(0..=ceiling_ms)) } fn ensure_trailing_slash(url: &str) -> String {