From 781301a77b3fbeaa58e769329ef1d63b428d0655 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 25 Oct 2026 21:27:05 +0100 Subject: [PATCH] feat(sharing,public-link): update public links, name bookmarks, walk shared trees - update_public_link sets the role, custom password and expiry of a node's public link and keeps its URL (TS updateSharedLink). Create and update share the sealing of the share key and SRP verifier. - list_bookmarks decrypts the bookmarked item's name with the share key the stored password unlocks, and returns the custom password in the new Bookmark::custom_password. The URL fragment is now only the generated password. - ProtonDrivePublicLinkClient::enumerate_subtree walks a shared folder breadth first, several folders of a level at a time. - Add CHANGELOG.md, update the README feature table for this and the previous unreleased commits, and record the TS ports in UPSTREAM_SYNC. --- CHANGELOG.md | 65 ++++ README.md | 11 +- UPSTREAM_SYNC.md | 1 + crates/proton-drive-rs/src/client.rs | 335 ++++++++++++++----- crates/proton-drive-rs/src/dtos.rs | 22 +- crates/proton-drive-rs/src/public_link.rs | 67 ++++ crates/proton-drive-rs/src/sharing.rs | 5 + crates/proton-drive-rs/tests/live_sharing.rs | 225 +++++++++++++ 8 files changed, 632 insertions(+), 99 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b38e35a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +Notable changes to `proton-sdk` and `proton-drive-rs`. The format follows +[Keep a Changelog](https://keepachangelog.com/1.1.0/); versions follow semver, +and both crates are released together under the workspace version. Upstream +commits a change ports are recorded in [UPSTREAM_SYNC.md](UPSTREAM_SYNC.md). + +This file starts after 0.9.2; earlier changes are in the git history. + +## [Unreleased] + +### Added + +- **Server-side copy.** `ProtonDriveClient::copy_node(uid, new_parent, name)` + copies a file, or a folder with everything in it, without downloading and + uploading it. The copy may get a new name and may land on another volume; the + returned uid is on the target volume. Ported from the TypeScript SDK + (`copyNode`). +- **Folder size.** `ProtonDriveClient::get_folder_size(uid)` returns a + `FolderSize` with the total size and the number of descendants, trashed nodes + included. Folders count as descendants, and the size is in encrypted bytes. + Ported from the TypeScript SDK (`getFolderSize`). +- **Changing a public link.** `ProtonDriveClient::update_public_link(uid, role, + custom_password, expiration_time)` sets the role, the custom password and the + expiry of the link on a node. The URL does not change. `None` removes a custom + password or an expiry. A legacy link cannot be updated. Ported from the + TypeScript SDK (`updateSharedLink`). +- **Walking a public link.** `ProtonDrivePublicLinkClient::enumerate_subtree` + and `enumerate_subtree_reporting` return every node below a shared folder, + breadth first, listing several folders of a level at once. +- **Bookmark names.** `list_bookmarks` now decrypts the bookmarked item's name + into `Bookmark::node_name`, and returns the link's custom password in the new + `Bookmark::custom_password` field. +- **Album capabilities from the C# SDK** (`a36cc423`): + - `ProtonPhotosClient::set_album_cover`; + - `ProtonPhotosClient::delete_album_saving_photos`, which on + `AlbumContainsPhotosNotInTimeline` (code 200302) saves the photos that are + only in the album to the timeline and deletes the album again; + - `add_photos_to_album` adds photos from another volume by copying them in, + instead of refusing them; + - a photo group the server reports as incomplete is retried once with the + missing related photos. +- `ResponseCode::AlbumContainsPhotosNotInTimeline`. +- `ApiHttpClient::post_query`, a POST that only reads and may be sent again. + +### Changed + +- **A read is sent again when its connection drops.** A GET, a block download or + a `post_query` that loses its connection mid-request or while reading the body + is retried under the client's retry policy. A write still is not: it may have + been applied. Read-only POSTs in `proton-drive-rs` (link details, thumbnails, + photo duplicates, `checkAvailableHashes`) now use `post_query`. +- `remove_photos_from_album` refuses a photo on another volume per photo, in + the input order, without sending it. + +### Fixed + +- A bookmark's URL fragment is now only the generated password, as in the URL + the web client shows. It used to include the custom password as well. + +### Breaking + +- `Bookmark` has a new public field, `custom_password`. Code that builds a + `Bookmark` with a struct literal must set it. +- `ResponseCode` has a new variant. An exhaustive `match` on it needs an arm. diff --git a/README.md b/README.md index f650ef3..34eb602 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented. | | Key-salt Injection | ⭐ | `with_key_salts` lets a client hand in cached salts and chain an entity repository onto it, so a warm start skips the salt round-trip. | | | Quota | ✅ | `quota()` — used/total space with `available()` / `used_fraction()` helpers. | | **HTTP Client** | Retries & Jitter | ✅ | Exponential backoff for 408, 429, and 5xx, respecting `Retry-After`. | +| | Read Replay | ⭐ | A read (GET, block download, `post_query`) whose connection drops mid-request or mid-body is sent again; a write is not, because it may have been applied. | | | Telemetry | ✅ | Pluggable request tracking (`ITelemetry` analogue). | | **Drive / Volume** | Volume Resolution | ✅ | Auto-resolves volume lists; auto-creates the `my-files` root volume on first login. | | | Node Listing | ✅ | `get_node`, `enumerate_folder_children`, `enumerate_trash` with key resolution. | @@ -272,6 +273,8 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented. | | Path Helpers | ⭐ | `get_node_by_path`, `get_node_path`, `get_node_hierarchy`, `create_folder_path`, `get_available_name` — a filesystem front-end wants paths, not UID walks. | | | File Operations | ✅ | Rename, trash, restore, delete, empty trash — batch calls report one outcome per node (`trash_nodes`), or stream them as each batch lands (`trash_nodes_streaming`). | | | Move Operations | ✅ | Same-volume move. Batch moves are chunked automatically, one outcome per node. | +| | Server-side Copy | 🟦 | `copy_node` copies a file or a whole folder on the server, optionally under a new name and onto another volume; no bytes pass through the client. | +| | Folder Size | 🟦 | `get_folder_size` asks the server for a folder's total size (encrypted bytes) and descendant count, trashed nodes included. | | | Cross-volume Move | ❌ | Not supported here, and not by `proton-drive-linux` either — the C# public API throws `NotImplementedException`, and a cross-volume move means re-uploading content under the destination volume's keys. | | **Events / Sync** | Event Enumeration | ✅ | `enumerate_events` + `latest_event_id` per volume scope; `invalidate_caches_for_event` keeps the entity cache coherent. | | | Event Manager | 🟦 | Background poll loop (`EventManager`) with a broadcast channel and a foreground/background interval switch, modelled on the TS `eventScheduler`. | @@ -294,12 +297,12 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented. | | Device Operations | ✅ | Rename and delete registered devices. | | **Sharing & Links** | Share Creation | 🟦 | `share_node` bootstraps a share on a node (no C# public API for it). | | | Member Management | ✅ | Invite users (`invite_users`), list members, update roles, and remove members. | -| | Public Share Links | 🟦 | Create public shareable links (`create_public_link`) with password protection and expiry; `get_public_link`, `remove_public_link`. | +| | Public Share Links | 🟦 | Create public shareable links (`create_public_link`) with password protection and expiry; `get_public_link`, `update_public_link` (role, custom password, expiry; the URL is kept), `remove_public_link`. | | | External Invites | ✅ | Invite non-Proton users (`invite_external_users`) and track registration status. | -| | Bookmarks | ✅ | Save, list, and delete public link bookmarks (`create_bookmark`, `list_bookmarks`, `delete_bookmark`). | +| | Bookmarks | ✅ | Save, list, and delete public link bookmarks (`create_bookmark`, `list_bookmarks`, `delete_bookmark`). A listed bookmark carries its URL, its custom password and the bookmarked item's name. | | | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user; `leave_shared_node`. | | | Shared Listings | ✅ | `enumerate_shared_by_me_node_uids`, `enumerate_shared_with_me` (drive and photos volumes). | -| | Public Link (consume) | 🟦 | Open someone else's link with no Proton account (`ProtonDrivePublicLinkClient`): SRP handshake, custom passwords, browse and download the shared subtree. | +| | Public Link (consume) | 🟦 | Open someone else's link with no Proton account (`ProtonDrivePublicLinkClient`): SRP handshake, custom passwords, browse and download the shared subtree, or walk all of it at once (`enumerate_subtree`). | | | Public Link streaming | ⭐ | Seekable range reads over a shared file (`open_revision` → `RevisionReader::read_at`), plus thumbnails, batch enumeration and in-place session renewal — a visitor streams a large file without downloading it. No TS or C# counterpart. | | **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. | | | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). | @@ -309,7 +312,7 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented. | | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. | | | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). | | | Save to Timeline | ✅ | `save_photos_to_timeline` moves photos already on our photos volume into the timeline root and copies cross-volume ones (from albums shared with us). | -| | Album Writes | ⚠️ | `create_album` and `add_photos_to_album` are ported from the TypeScript SDK (C# has no public album write API); album rename, cover photo and delete are not. | +| | Album Writes | ✅ | `create_album`, `rename_album`, `set_album_cover`, `add_photos_to_album` (photos from another volume are copied in), `remove_photos_from_album`, `delete_album`, and `delete_album_saving_photos`, which first saves photos that are only in the album to the timeline. | | | Photos Volume Create| ❌ | Volume creation is not yet ported. | | **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. | | **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). | diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index ad4dc25..a5bb39b 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -44,3 +44,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-10-15 | `df3a717b` → `8ffae007` | `af391cbb` | ported: **folder-aware available names** (`af391cbb`, breaking upstream and here): `get_available_name` takes an `AvailableNameTarget` (`File` / `Folder`). A file keeps its extension last (`name (1).ext`, as before); a folder appends to the whole name (`name (1)`), so a folder named `v1.2` no longer resolves to `v1 (1).2`. `alternate_names` split into `alternate_file_names` / `alternate_folder_names` (C# `AlternateFileNameGenerator.GetFileNames` / `GetFolderNames`). no-op: `c6b45188` (C# transfer buffers optionally backed by an unlinked `mkstemp` file instead of `RecyclableMemoryStream`, behind a compile-time flag) — memory plumbing only; our blocks are `Vec` bounded by the client-wide in-flight block semaphore, and the verifier prefix it now reads from the stream is the same `min(prefix, len)` bytes our `verification_token` already zero-pads. **Downstream:** no `get_available_name` caller in `proton-drive-linux` or `proton-stream`. | | 2026-10-25 | — (no C# delta) | — | **not an upstream port of C#** — two ports from the **TypeScript** SDK, plus one change of ours. **Folder size** (TS `getFolderSize`, `243f11f4`): `ProtonDriveClient::get_folder_size` → `GET volumes/{vid}/folders/{lid}/calculate-descendents-size`, returning `FolderSize { size, descendants }`. Live-verified, which settled what the TS docs leave open: folders count as descendants, and the size is encrypted bytes on storage (two single-block files of 301 000 plaintext bytes report 301 112). **Copy** (TS `NodesManagement.copyNode`): `ProtonDriveClient::copy_node(uid, new_parent, name)` → `POST volumes/{vid}/links/{lid}/copy`, a server-side copy of a file with its content or a folder with its subtree. The crypto is the move's (`build_move_parts`: passphrase rewrapped to the new parent, name encrypted and signed by the My Files address, signature fields only for an anonymously signed node); `CopyPhotoResponse` became the shared `CopyLinkResponse`. Diverged deliberately: the returned uid takes the **target** parent's volume, where TS uses the source's, which only holds within one volume; no `copyNodes` batch, which in TS is a sequential loop over `copyNode`. Live-verified: file content, rename-on-copy, folder subtree, and `AlreadyExists` for a taken name. **Ours**: reads are retried after a dropped connection — `send_retrying` reads the body inside its loop and takes a `Replay` mode; a `GET`, a block download and the new `ApiHttpClient::post_query` (link details, thumbnail URLs, photo duplicates, `checkAvailableHashes`) are sent again when the connection drops mid-request or mid-body, while writes keep the old rule. | | 2026-10-25 | `8ffae007` → `b68d64bd` | `a36cc423` | ported: **album capabilities** (`a36cc423`), the C# album write API, against the TS-sourced album writes we already had. `add_photos_to_album` now goes through the timeline transfer driver, renamed `transfer_photos` over a `PhotoTransferTarget` with a `PhotoTransferKind` (C# `TransferPhotosToTargetAsync` / `PhotoTransferTarget`): same-volume photos through `add-multiple` (the per-link answers share `TransferPhotosResponse`, so `AddPhotosToAlbumResponse` is gone), photos on **another volume** copied into the album through `POST volumes/{vid}/links/{lid}/copy` instead of failing, and a photo whose group the server reports incomplete re-sent once with the related photos it named; a related photo that cannot be re-encrypted is skipped rather than failing its main photo. The call still answers in input order. `add-multiple` entries never carry a passphrase signature (C# `ToAlbumPhotoLinkItem`). New `ProtonPhotosClient::set_album_cover` (the cover half of `UpdateAlbumAsync`; same volume only, refused before sending), and `AlbumUpdateRequest` now omits a `None` cover or link like C#. New `delete_album_saving_photos` (`DeleteAlbumAsync` with `saveToTimeline`): on `ResponseCode::AlbumContainsPhotosNotInTimeline` (200302, new) it saves the photos `Details.ChildLinkIDs` names, then deletes again; the first photo that cannot be saved fails the call. `remove_photos_from_album` fails a photo on another volume without sending it. Kept additive: `delete_album(uid, delete_photos)` and `rename_album` keep their signatures (`proton-drive-linux` calls both) rather than taking C#'s `(force, saveToTimeline)` and `(name?, cover?)`. Live-verified on the test account: add, adding twice (`Photo already exists in the album.`), cover, remove, delete keeping photos. Not live-verified: the cross-volume copy and the 200302 save, which need a second account sharing an album. **not ported: experimental search** (`e861ef99`): the engine is a Rust crate under `incubating/search` whose source is not in the public tree — only its C# bindings' wiring (`Search.targets`, `ProtonDriveSearchClient`) and a `BinaryKeyValueStore` for its index are. The rest of that commit is cosmetic (`await foreach` over a local, `SqliteConnectionStringBuilder`). | +| 2026-10-25 | — (no C# delta) | — | **Not an upstream port from C#**: three sharing gaps closed from the **TypeScript** SDK. **Public link update** (TS `SharingManagement.updateSharedLink` / `apiService.updatePublicLink`): `ProtonDriveClient::update_public_link(uid, role, custom_password, expiration_time)` → `PUT shares/{sid}/urls/{urlID}`. The generated password is taken from the recovered URL fragment and must be 12 characters (TS refuses legacy links the same way); the share key and SRP verifier are sealed again under it plus the new custom password. Create and update now share `seal_public_link_settings` and a flattened `PublicLinkSettingsDto`; update sends `ExpirationTime: null` to clear an expiry, as TS does. **Bookmark names** (TS `SharingCryptoService.decryptBookmark`): the whole stored password, bcrypt-derived with `SharePasswordSalt`, unlocks the share key, which decrypts `Token.Name`; signatures are not checked, as in TS. `Bookmark` gains `custom_password` (TS `splitGeneratedAndCustomPassword`), and the URL fragment is now only the generated 12 characters. **Ours**: `ProtonDrivePublicLinkClient::enumerate_subtree` walks a shared folder breadth first, 6 folders per level at once (lifted from proton-stream's crawl; neither SDK has it). Live-verified on the test account: update to Editor + custom password + expiry, then clear both; bookmark on our own link lists its name and custom password; a nested subtree walks in level order. | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 9243e68..9748bbe 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -22,9 +22,10 @@ use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::{ ContentKey, DEFAULT_BIT_LENGTH, SHARING_INVITER_CONTEXT, VerificationKeyRing, VerificationStatus, accept_invitation, build_standard_share_material, - build_volume_creation_material, decrypt_armored_with_keys, derive_key_passphrase, - encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, generate_node_key, - generate_node_key_aead, generate_verifier, verify_detached, verify_detached_with_context, + build_volume_creation_material, decrypt_armored_with_keys, decrypt_armored_with_password, + derive_key_passphrase, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, + generate_node_key, generate_node_key_aead, generate_verifier, verify_detached, + verify_detached_with_context, }; use proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_sdk::http::ApiHttpClient; @@ -49,13 +50,13 @@ use crate::dtos::{ AlbumCreationLink, AlbumCreationRequest, AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, AlbumUpdateLink, AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, - BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, - CommonExtendedAttributes, ContextShareResponse, CopyLinkRequest, CopyLinkResponse, - CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest, CreateBookmarkRequest, - CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, - DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, - DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, - ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, + BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarkTokenDto, + BookmarksResponse, CommonExtendedAttributes, ContextShareResponse, CopyLinkRequest, + CopyLinkResponse, CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest, + CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, + CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, + DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, + DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse, @@ -65,7 +66,7 @@ use crate::dtos::{ LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, - PhotosAttributesDto, RemovePhotosFromAlbumRequest, RenameLinkRequest, + PhotosAttributesDto, PublicLinkSettingsDto, RemovePhotosFromAlbumRequest, RenameLinkRequest, ReportRecentlyAccessedItem, ReportRecentlyAccessedRequest, RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, @@ -74,8 +75,9 @@ use crate::dtos::{ SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, TransferPhotoLinkItem, - TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, - VolumeEventDto, VolumeEventListResponse, VolumeListResponse, VolumeTrashResponse, + TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, + UpdatePublicLinkRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, + VolumeListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ @@ -3755,15 +3757,7 @@ impl ProtonDriveClient { 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 permissions = public_link_permissions(role)?; let (share_id, share_session_key, creator_email, address_key) = self.ensure_node_share(uid).await?; @@ -3771,65 +3765,21 @@ impl ProtonDriveClient { // 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(), - }; - - // The modulus is fetched first so that everything CPU-bound below can go - // to the blocking pool in one hop. - let modulus = self.fetch_srp_modulus().await?; - - // Wrap the share session key with the SRP-salted passphrase, store the - // password encrypted to the owner's address key, and build the SRP - // verifier over the link password. - // - // All of that is pure CPU and none of it is cheap: `derive_key_passphrase` - // is a deliberately slow bcrypt, and `generate_verifier` a 2048-bit modular - // exponentiation. Inline, they stalled every other task on the reactor - // thread for the duration — so, like the rest of the crate's PGP work, they - // run on the blocking pool. - let key_salt = generate_key_salt(); - let signed_modulus = modulus.modulus.clone(); - let password_bytes = full_password.clone(); - let salt_for_task = key_salt; - let (share_passphrase_key_packet, armored_password, verifier) = - tokio::task::spawn_blocking(move || { - let salted_passphrase = - derive_key_passphrase(password_bytes.as_bytes(), &salt_for_task)?; - let key_packet = - base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?); - let armored_password = address_key.encrypt(password_bytes.as_bytes())?; - let verifier = generate_verifier( - password_bytes.as_bytes(), - &signed_modulus, - DEFAULT_BIT_LENGTH, - )?; - Ok::<_, ProtonError>((key_packet, armored_password, verifier)) - }) - .await - .map_err(|e| { - ProtonError::invalid_operation(format!("public link crypto task failed: {e}")) - })??; - - let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) { - 3 // random + custom password - } else { - 2 // random password only - }; + let settings = self + .seal_public_link_settings( + permissions, + &generated, + custom_password, + share_session_key, + address_key, + ) + .await?; + let has_custom_password = settings.flags == 3; 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, + settings, }; let path = format!("shares/{share_id}/urls"); @@ -3843,7 +3793,134 @@ impl ProtonDriveClient { role, creation_time: now_epoch_seconds(), expiration_time, - has_custom_password: flags == 3, + has_custom_password, + }) + } + + /// Change the options of the public link on a node: its role, its custom + /// password and its expiry (`PUT shares/{sid}/urls/{urlID}`). Each call sets + /// all three: `None` removes a custom password or an expiry the link had. + /// + /// Ported from the TypeScript SDK (`SharingManagement.updateSharedLink`). The + /// URL does not change: the generated password in its fragment is kept, and + /// the share key and SRP verifier are sealed again under it plus the new + /// custom password. A legacy link, whose fragment is not a generated + /// 12-character password, cannot be updated; remove it and create a new one. + pub async fn update_public_link( + &self, + uid: &NodeUid, + role: MemberRole, + custom_password: Option<&str>, + expiration_time: Option, + ) -> Result { + let mut timer = self.telemetry.start("update_public_link"); + let permissions = public_link_permissions(role)?; + + let link = self + .get_public_link(uid) + .await? + .ok_or_else(|| ProtonError::invalid_operation("the node has no public link"))?; + let generated = link + .url + .as_deref() + .and_then(|url| url.split_once('#')) + .map(|(_, fragment)| fragment.to_owned()) + .filter(|fragment| fragment.chars().count() == PUBLIC_LINK_PASSWORD_LEN) + .ok_or_else(|| { + ProtonError::invalid_operation( + "a legacy public link cannot be updated; remove it and create a new one", + ) + })?; + + let (_, share_session_key, _, address_key) = self.ensure_node_share(uid).await?; + let settings = self + .seal_public_link_settings( + permissions, + &generated, + custom_password, + share_session_key, + address_key, + ) + .await?; + let has_custom_password = settings.flags == 3; + + let request = UpdatePublicLinkRequest { + expiration_time, + settings, + }; + let path = format!("shares/{}/urls/{}", link.share_id, link.public_link_id); + let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; + + timer.success(); + Ok(PublicLink { + role, + expiration_time, + has_custom_password, + ..link + }) + } + + /// Seal a public link's share key and SRP verifier under its password: the + /// generated password, with `custom_password` appended when there is one. + /// + /// The share session key is wrapped with the bcrypt-salted passphrase + /// (`SharePassphraseKeyPacket`), the password is stored encrypted to the + /// owner's address key, and the SRP verifier lets the server authenticate + /// visitors without learning the password. + async fn seal_public_link_settings( + &self, + permissions: i32, + generated: &str, + custom_password: Option<&str>, + share_session_key: ContentKey, + address_key: PrivateKey, + ) -> Result { + let custom_password = custom_password.filter(|p| !p.is_empty()); + let full_password = format!("{generated}{}", custom_password.unwrap_or("")); + + // The modulus is fetched first so that everything CPU-bound below can go + // to the blocking pool in one hop. + let modulus = self.fetch_srp_modulus().await?; + + // All of that is pure CPU and none of it is cheap: `derive_key_passphrase` + // is a deliberately slow bcrypt, and `generate_verifier` a 2048-bit modular + // exponentiation. Inline, they stalled every other task on the reactor + // thread for the duration — so, like the rest of the crate's PGP work, they + // run on the blocking pool. + let key_salt = generate_key_salt(); + let signed_modulus = modulus.modulus; + let (share_passphrase_key_packet, armored_password, verifier) = + tokio::task::spawn_blocking(move || { + let salted_passphrase = derive_key_passphrase(full_password.as_bytes(), &key_salt)?; + let key_packet = + base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?); + let armored_password = address_key.encrypt(full_password.as_bytes())?; + let verifier = generate_verifier( + full_password.as_bytes(), + &signed_modulus, + DEFAULT_BIT_LENGTH, + )?; + Ok::<_, ProtonError>((key_packet, armored_password, verifier)) + }) + .await + .map_err(|e| { + ProtonError::invalid_operation(format!("public link crypto task failed: {e}")) + })??; + + Ok(PublicLinkSettingsDto { + permissions, + flags: if custom_password.is_some() { + 3 // random + custom password + } else { + 2 // random password only + }, + 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, }) } @@ -4120,8 +4197,9 @@ impl ProtonDriveClient { // ---- 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). + /// (`GET v2/shared-bookmarks`). A bookmark whose stored password cannot be + /// decrypted is skipped; the item name is `None` when the share key it is + /// encrypted to cannot be unlocked or the name is not valid. 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?; @@ -4138,20 +4216,24 @@ impl ProtonDriveClient { 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(); + // The stored secret is the generated URL password with any custom + // password appended (TS `splitGeneratedAndCustomPassword`). The URL + // fragment is the generated part; the share key is locked with the + // whole string. + let password = String::from_utf8_lossy(&bytes).into_owned(); + let (url_password, custom_password) = split_bookmark_password(&password); + let url = format!( + "https://drive.proton.me/urls/{}#{url_password}", + dto.token.token + ); + let custom_password = custom_password.map(str::to_owned); + let node_name = decrypt_bookmark_name(password, &dto.token).await; bookmarks.push(Bookmark { - url: format!( - "https://drive.proton.me/urls/{}#{url_password}", - dto.token.token - ), + url, 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, + custom_password, + node_name, is_folder: dto.token.link_type == 1, creation_time: dto.create_time, }); @@ -7987,6 +8069,53 @@ const PUBLIC_LINK_PASSWORD_CHARSET: &[u8] = /// Generated public-link password length (JS `PUBLIC_LINK_GENERATED_PASSWORD_LENGTH`). const PUBLIC_LINK_PASSWORD_LEN: usize = 12; +/// The permission mask a public link may grant: Viewer or Editor, never Admin. +fn public_link_permissions(role: MemberRole) -> Result { + role.to_permissions() + .filter(|_| matches!(role, MemberRole::Viewer | MemberRole::Editor)) + .ok_or_else(|| { + ProtonError::invalid_operation("a public link can only grant the Viewer or Editor role") + }) +} + +/// Split a bookmark's stored secret into the generated URL password and the +/// custom password appended to it, if any (TS `splitGeneratedAndCustomPassword`). +fn split_bookmark_password(password: &str) -> (&str, Option<&str>) { + let split = password + .char_indices() + .nth(PUBLIC_LINK_PASSWORD_LEN) + .map_or(password.len(), |(i, _)| i); + let (generated, custom) = password.split_at(split); + (generated, (!custom.is_empty()).then_some(custom)) +} + +/// Decrypt a bookmarked item's name (TS `SharingCryptoService.decryptBookmark`). +/// +/// The share key is locked the way a visitor unlocks it: the whole stored +/// password, bcrypt-derived with `SharePasswordSalt`, opens `SharePassphrase`. +/// The name is encrypted to the share key. Signatures are not checked, as in +/// TS. Any failure, or a name that would not pass validation, yields `None`: +/// the URL still opens the link. +/// +/// Runs on the blocking pool: the bcrypt derivation and the share key's S2K +/// are each tens of milliseconds of CPU, once per bookmark. +async fn decrypt_bookmark_name(password: String, token: &BookmarkTokenDto) -> Option { + let salt = BASE64.decode(&token.share_password_salt).ok()?; + let armored_passphrase = token.share_passphrase.clone(); + let armored_share_key = token.share_key.clone(); + let armored_name = token.name.clone(); + tokio::task::spawn_blocking(move || { + let key_password = derive_key_passphrase(password.as_bytes(), &salt).ok()?; + let passphrase = decrypt_armored_with_password(&armored_passphrase, &key_password).ok()?; + let share_key = PrivateKey::from_armored(&armored_share_key, &passphrase).ok()?; + let name_bytes = share_key.decrypt_armored_message(&armored_name).ok()?; + let name = String::from_utf8(name_bytes).ok()?; + validate_node_name(&name).ok().map(|()| name) + }) + .await + .ok()? +} + /// Generate a random 12-character alphanumeric public-link password. fn generate_public_link_password() -> String { let mut raw = [0u8; PUBLIC_LINK_PASSWORD_LEN]; @@ -8855,8 +8984,8 @@ mod tests { is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys, run_context_share_mutation, share_membership_from_dto, small_upload_applicable, - small_upload_should_fall_back, take_block_target, take_thumbnail_target, to_drive_event, - validate_node_name, + small_upload_should_fall_back, split_bookmark_password, take_block_target, + take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair, @@ -8873,6 +9002,26 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::{Mutex, Notify, RwLock}; + #[test] + fn a_bookmark_password_splits_into_the_generated_part_and_the_custom_one() { + assert_eq!( + split_bookmark_password("AbCdEf123456"), + ("AbCdEf123456", None) + ); + assert_eq!( + split_bookmark_password("AbCdEf123456s3cr3t!"), + ("AbCdEf123456", Some("s3cr3t!")) + ); + // A custom password may hold any character; the split counts chars, + // not bytes, and never cuts one in half. + assert_eq!( + split_bookmark_password("AbCdEf123456pässwört"), + ("AbCdEf123456", Some("pässwört")) + ); + // A legacy password shorter than a generated one is all URL password. + assert_eq!(split_bookmark_password("short"), ("short", None)); + } + /// A batch envelope with one successful link and one that failed with `code`. fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse { AggregateLinksResponse { diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 30a008e..43efa4b 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -2389,12 +2389,30 @@ pub struct ModulusResponse { pub struct CreatePublicLinkRequest { #[serde(rename = "CreatorEmail")] pub creator_email: String, + #[serde(rename = "ExpirationTime", skip_serializing_if = "Option::is_none")] + pub expiration_time: Option, + #[serde(flatten)] + pub settings: PublicLinkSettingsDto, +} + +/// `PUT shares/{shareID}/urls/{urlID}`. Unlike create, an absent expiry is +/// sent as `null`, which clears an expiry the link had (TS `updatePublicLink`). +#[derive(Debug, Serialize)] +pub struct UpdatePublicLinkRequest { + #[serde(rename = "ExpirationTime")] + pub expiration_time: Option, + #[serde(flatten)] + pub settings: PublicLinkSettingsDto, +} + +/// The fields create and update share: the role, the password flags, and the +/// share key + SRP verifier sealed under the link password. +#[derive(Debug, Serialize)] +pub struct PublicLinkSettingsDto { #[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")] diff --git a/crates/proton-drive-rs/src/public_link.rs b/crates/proton-drive-rs/src/public_link.rs index 4dad737..d068642 100644 --- a/crates/proton-drive-rs/src/public_link.rs +++ b/crates/proton-drive-rs/src/public_link.rs @@ -66,6 +66,12 @@ use crate::sharing::MemberRole; use crate::single_flight::SingleFlight; use crate::transport::{BlockSession, RevisionTransport, rank_block_sizes}; +/// How many folders of one level [`ProtonDrivePublicLinkClient::enumerate_subtree`] +/// lists at once. Each listing fans out again into up to +/// [`MAX_CONCURRENT_DETAIL_FETCHES`] detail batches, so this multiplies the +/// requests in flight; six keeps them in the low tens. +const MAX_CONCURRENT_FOLDER_LISTINGS: usize = 6; + /// Link metadata readable before authenticating, from `GET urls/{token}/info`. /// /// Fetch it with [`ProtonDrivePublicLinkClient::info`] to learn whether the user @@ -522,6 +528,67 @@ impl ProtonDrivePublicLinkClient { Ok(uids) } + /// Every node below a shared folder, breadth first: the folder's children, + /// then their children, and so on. The folder itself is not included. + /// + /// The authenticated client walks a tree with `get_node_hierarchy`; a + /// visitor has no such endpoint, so this lists one folder per request. A + /// whole level is listed at once, six folders at a time, because a shared + /// library is wide: hundreds of sibling folders, each one listing plus a + /// detail fetch, which one at a time is minutes of round trips. Nodes come back in a stable order: by level, then in the + /// order their folders were listed. + /// + /// A failed listing fails the walk. A caller that walks large shares over a + /// flaky network may want to wrap [`enumerate_folder_children_node_uids`] + /// with its own retry instead. + /// + /// [`enumerate_folder_children_node_uids`]: Self::enumerate_folder_children_node_uids + pub async fn enumerate_subtree(&self, folder_uid: &NodeUid) -> Result> { + self.enumerate_subtree_reporting(folder_uid, |_| {}).await + } + + /// As [`enumerate_subtree`](Self::enumerate_subtree), calling `progress` + /// with the number of nodes found so far each time a folder's listing + /// comes back. A walk of a large share takes minutes; a rising count shows + /// it is not stuck. + pub async fn enumerate_subtree_reporting( + &self, + folder_uid: &NodeUid, + progress: impl Fn(usize), + ) -> Result> { + let mut level = vec![folder_uid.clone()]; + let mut found = Vec::new(); + + while !level.is_empty() { + let listings = level.into_iter().map(|folder| { + let client = self.clone(); + async move { client.list_folder(&folder).await } + }); + let mut listings = stream::iter(listings).buffered(MAX_CONCURRENT_FOLDER_LISTINGS); + + let mut next = Vec::new(); + while let Some(children) = listings.try_next().await? { + next.extend( + children + .iter() + .filter(|child| child.is_folder()) + .map(|child| child.uid.clone()), + ); + found.extend(children); + progress(found.len()); + } + level = next; + } + + Ok(found) + } + + /// One folder's children, decrypted. + async fn list_folder(&self, folder_uid: &NodeUid) -> Result> { + let child_uids = self.enumerate_folder_children_node_uids(folder_uid).await?; + self.enumerate_nodes(&child_uids).await + } + /// Fetch decrypted metadata for many nodes in the shared subtree. /// /// A node that does not exist is omitted; one that fails to decrypt is diff --git a/crates/proton-drive-rs/src/sharing.rs b/crates/proton-drive-rs/src/sharing.rs index 65cbb7a..b3d31c3 100644 --- a/crates/proton-drive-rs/src/sharing.rs +++ b/crates/proton-drive-rs/src/sharing.rs @@ -257,6 +257,11 @@ pub struct Bookmark { pub token: String, /// The full public URL, including the `#password` fragment. pub url: String, + /// The link's custom password, when it has one. The URL alone does not + /// open such a link; pass this to + /// [`ProtonDrivePublicLinkClient::open`](crate::ProtonDrivePublicLinkClient::open) + /// as well. + pub custom_password: Option, /// The bookmarked item's decrypted name, when it could be decrypted. pub node_name: Option, /// Whether the bookmarked item is a folder. diff --git a/crates/proton-drive-rs/tests/live_sharing.rs b/crates/proton-drive-rs/tests/live_sharing.rs index b874bf9..de4aea3 100644 --- a/crates/proton-drive-rs/tests/live_sharing.rs +++ b/crates/proton-drive-rs/tests/live_sharing.rs @@ -787,3 +787,228 @@ async fn a_visitor_can_download_thumbnails_of_shared_files() { cleanup(client, &folder).await; } + +// --------------------------------------------------------------------------- +// Public link — changing options, walking the subtree, bookmarks +// --------------------------------------------------------------------------- + +/// Updating a link keeps its URL and applies the new role, custom password and +/// expiry; a visitor then needs the new password, and a second update clears +/// both the password and the expiry again. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn update_public_link_changes_role_password_and_expiry() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "public-update").await; + let link = client + .create_public_link(&folder, MemberRole::Viewer, None, None) + .await + .expect("create_public_link"); + let url = link.url.clone().expect("a created link must carry its URL"); + + let custom = "n3w-s3cr3t"; + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + 7 * 24 * 3600; + let updated = client + .update_public_link(&folder, MemberRole::Editor, Some(custom), Some(expiry)) + .await + .expect("update_public_link"); + assert_eq!(updated.public_link_id, link.public_link_id); + assert_eq!( + updated.url.as_deref(), + Some(url.as_str()), + "the URL is kept" + ); + assert_eq!(updated.role, MemberRole::Editor); + assert!(updated.has_custom_password); + assert_eq!(updated.expiration_time, Some(expiry)); + + let fetched = client + .get_public_link(&folder) + .await + .expect("get_public_link") + .expect("link must still exist"); + assert_eq!(fetched.role, MemberRole::Editor, "the role must be stored"); + assert!( + fetched.has_custom_password, + "the password flag must be stored" + ); + assert_eq!( + fetched.expiration_time, + Some(expiry), + "the expiry must be stored" + ); + assert_eq!(fetched.url.as_deref(), Some(url.as_str())); + + assert!( + ProtonDrivePublicLinkClient::open(common::config(), &url, None) + .await + .is_err(), + "the link must now need the custom password" + ); + let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, Some(custom)) + .await + .expect("open with the new custom password"); + assert_eq!(visitor.public_role(), MemberRole::Editor); + + let cleared = client + .update_public_link(&folder, MemberRole::Viewer, None, None) + .await + .expect("update_public_link (clear)"); + assert!(!cleared.has_custom_password); + let fetched = client + .get_public_link(&folder) + .await + .expect("get_public_link") + .expect("link must still exist"); + assert!( + !fetched.has_custom_password, + "the custom password is removed" + ); + assert_eq!(fetched.expiration_time, None, "the expiry is cleared"); + ProtonDrivePublicLinkClient::open(common::config(), &url, None) + .await + .expect("the link opens with the URL alone again"); + + assert!( + client + .update_public_link(&folder, MemberRole::Admin, None, None) + .await + .is_err(), + "a public link cannot grant Admin" + ); + + client + .remove_public_link(&link) + .await + .expect("remove_public_link"); + cleanup(client, &folder).await; +} + +/// A visitor walks the whole shared tree, nested folders included, without +/// the start folder itself. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn a_visitor_enumerates_the_whole_shared_subtree() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "public-walk").await; + let a = client + .create_folder(&folder, "a", None) + .await + .expect("create a"); + let b = client.create_folder(&a, "b", None).await.expect("create b"); + let top = client + .upload_file(&folder, "top.txt", "text/plain", b"top") + .await + .expect("upload top"); + let deep = client + .upload_file(&b, "deep.txt", "text/plain", b"deep") + .await + .expect("upload deep"); + + let link = client + .create_public_link(&folder, MemberRole::Viewer, None, None) + .await + .expect("create_public_link"); + let url = link.url.clone().expect("a created link must carry its URL"); + let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, None) + .await + .expect("open public link"); + + let progress = std::sync::atomic::AtomicUsize::new(0); + let nodes = visitor + .enumerate_subtree_reporting(&folder, |n| { + progress.store(n, std::sync::atomic::Ordering::Relaxed) + }) + .await + .expect("enumerate_subtree"); + + let mut uids: Vec<_> = nodes.iter().map(|n| n.uid.clone()).collect(); + uids.sort_by_key(|uid| uid.link_id.to_string()); + let mut want = vec![a.clone(), b.clone(), top.clone(), deep.clone()]; + want.sort_by_key(|uid| uid.link_id.to_string()); + assert_eq!(uids, want, "every descendant, and not the start folder"); + assert_eq!(progress.into_inner(), 4, "the last report is the total"); + + // Breadth first: the first level comes before anything below it. + let position = |uid: &NodeUid| nodes.iter().position(|n| &n.uid == uid).unwrap(); + assert!(position(&a) < position(&b)); + assert!(position(&top) < position(&b)); + assert!(position(&b) < position(&deep)); + + client + .remove_public_link(&link) + .await + .expect("remove_public_link"); + cleanup(client, &folder).await; +} + +/// A bookmark lists with its item's name, the URL fragment without the custom +/// password, and the custom password on its own, and is gone once deleted. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn bookmarks_list_with_their_name_and_custom_password() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "public-bookmark").await; + let folder_name = client + .get_node(&folder) + .await + .expect("get_node") + .expect("folder exists") + .name; + let custom = "b00kmark"; + let link = client + .create_public_link(&folder, MemberRole::Viewer, Some(custom), None) + .await + .expect("create_public_link"); + let url = link.url.clone().expect("a created link must carry its URL"); + + client + .create_bookmark(&url, Some(custom)) + .await + .expect("create_bookmark"); + + let bookmarks = client.list_bookmarks().await.expect("list_bookmarks"); + let bookmark = bookmarks + .iter() + .find(|b| url.contains(&b.token)) + .expect("the new bookmark must be listed"); + assert_eq!( + bookmark.url, url, + "the URL carries the generated password only" + ); + assert_eq!(bookmark.custom_password.as_deref(), Some(custom)); + assert_eq!(bookmark.node_name.as_deref(), Some(folder_name.as_str())); + assert!(bookmark.is_folder); + + client + .delete_bookmark(&bookmark.token) + .await + .expect("delete_bookmark"); + let bookmarks = client.list_bookmarks().await.expect("list_bookmarks"); + assert!( + bookmarks.iter().all(|b| !url.contains(&b.token)), + "a deleted bookmark must not be listed" + ); + + client + .remove_public_link(&link) + .await + .expect("remove_public_link"); + cleanup(client, &folder).await; +}