Files
proton-sdk-rs/crates/proton-drive-rs/src/public_link.rs
T

1294 lines
52 KiB
Rust

//! Consuming a public share link — the read side of someone *else's* link.
//!
//! Ported from the TypeScript SDK (`internal/sharingPublic`, exposed as
//! `ProtonDrivePublicLinkClient`); the C# SDK has no equivalent.
//! [`ProtonDriveClient::create_public_link`](crate::ProtonDriveClient::create_public_link)
//! is the mirror image of this module — it *mints* the link this opens.
//!
//! A visitor needs no Proton account. The flow is:
//!
//! 1. `GET urls/{token}/info` (unauthenticated) opens an SRP handshake and says
//! whether a custom password is also required.
//! 2. `POST urls/{token}/auth` completes it, returning an anonymous session
//! (`x-pm-uid` + bearer) and the link's encrypted share key.
//! 3. The share key is unlocked with the link password: bcrypt-derive the key
//! password from `SharePasswordSalt`, symmetrically decrypt `SharePassphrase`
//! with it, then unlock `ShareKey` with the recovered passphrase.
//!
//! After that the *ordinary* Drive endpoints serve the shared subtree — only the
//! session headers and the root key differ — so node listing and download reuse
//! the same wire shapes as [`crate::ProtonDriveClient`].
//!
//! ## Verification
//!
//! Signature verification is deliberately absent here. Verifying authorship
//! needs the owner's public keys from `core/v4/keys/all`, which an anonymous
//! visitor cannot read; the TypeScript SDK disables the same fallback
//! (`SharingPublicNodesCryptoService.allowContentKeyPacketFallbackVerification =
//! false`). Nodes come back with a default [`NodeVerification`], i.e. nothing
//! claimed, rather than a verification that silently passed.
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use futures::stream::{self, StreamExt, TryStreamExt};
use lru::LruCache;
use tokio::sync::Semaphore;
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::crypto::{
ContentKey, DEFAULT_BIT_LENGTH, PrivateKey, decrypt_armored_with_password,
derive_key_passphrase, generate_proofs,
};
use proton_sdk::error::{ProtonError, Result};
use proton_sdk::http::{ApiHttpClient, Tokens, get_unauthenticated};
use proton_sdk::ids::{LinkId, NodeUid, SessionId, VolumeId};
use crate::client::{
DEFAULT_MAX_INFLIGHT_BLOCKS, MAX_BATCH_COUNT, MAX_CONCURRENT_DETAIL_FETCHES,
MAX_THUMBNAIL_IDS_PER_REQUEST, parse_public_link_url,
};
use crate::crypto::{decrypt_extended_attributes_unverified, decrypt_link};
use crate::dtos::{
FileDto, FolderChildrenResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse,
LinkType, PublicLinkAuthRequest, PublicLinkAuthResponse, PublicLinkInfoResponse,
ThumbnailBlockListRequest, ThumbnailBlockListResponse,
};
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, ThumbnailType};
use crate::revision::{MAX_CONCURRENT_BLOCK_DOWNLOADS, RevisionReader, decrypt_block_blocking};
use crate::sharing::MemberRole;
use crate::single_flight::SingleFlight;
use crate::transport::{BlockSession, RevisionTransport, rank_block_sizes};
/// Link metadata readable before authenticating, from `GET urls/{token}/info`.
///
/// Fetch it with [`ProtonDrivePublicLinkClient::info`] to learn whether the user
/// must also be prompted for a custom password before
/// [`open`](ProtonDrivePublicLinkClient::open) can succeed.
#[derive(Debug, Clone)]
pub struct PublicLinkInfo {
/// The link needs a custom password in addition to the URL fragment.
pub is_custom_password_protected: bool,
/// A legacy link, which neither this SDK nor the upstream TypeScript SDK
/// can open.
pub is_legacy: bool,
/// Non-zero for links owned by another Proton app (Docs and friends) rather
/// than by Drive.
pub vendor_type: i32,
}
/// Everything needed to run the handshake, and nothing that a completed
/// handshake produced.
///
/// Split out from [`PublicLinkSession`] so a session can be *constructed* around
/// an already-authenticated client while still being able to replay the
/// handshake later — the credentials outlive any particular session.
struct Credentials {
config: ProtonClientConfiguration,
token: String,
/// URL fragment password concatenated with any custom password — the TS SDK
/// treats the pair as one secret throughout.
password: String,
}
/// What one completed handshake yields.
struct AuthOutcome {
http: ApiHttpClient,
share_key: PrivateKey,
root_uid: NodeUid,
public_role: MemberRole,
}
impl Credentials {
async fn info(&self) -> Result<PublicLinkInfoResponse> {
get_unauthenticated(&self.config, &format!("drive/urls/{}/info", self.token)).await
}
/// Run the SRP handshake and unlock the share key.
async fn auth(&self) -> Result<AuthOutcome> {
let info = self.info().await?;
if info.is_legacy() {
return Err(ProtonError::invalid_operation(
"this public link uses a legacy format that is no longer supported",
));
}
// SRP proofs are a bcrypt derivation plus 2048-bit modular
// exponentiations — tens of milliseconds of pure CPU. Inline they stalled
// the reactor thread; the crate offloads its crypto everywhere else
// (`crypto::decrypt_link_verified`) and this is no different.
let version = info.version;
let password = self.password.clone();
let url_password_salt = decode_base64(&info.url_password_salt, "UrlPasswordSalt")?;
let modulus = info.modulus.clone();
let server_ephemeral = decode_base64(&info.server_ephemeral, "ServerEphemeral")?;
let proofs = blocking(move || {
Ok(generate_proofs(
version,
password.as_bytes(),
&url_password_salt,
&modulus,
&server_ephemeral,
DEFAULT_BIT_LENGTH,
)?)
})
.await?;
let request = PublicLinkAuthRequest {
client_proof: BASE64.encode(&proofs.client_proof),
client_ephemeral: BASE64.encode(&proofs.client_ephemeral),
srp_session: info.srp_session,
};
let response: PublicLinkAuthResponse = proton_sdk::http::post_unauthenticated(
&self.config,
&format!("drive/urls/{}/auth", self.token),
&request,
)
.await?;
// The server proves it knows the verifier. A mismatch means we are not
// talking to the party that holds the link's secret — refuse before
// handing it any further requests.
if response.server_proof != BASE64.encode(&proofs.expected_server_proof) {
return Err(ProtonError::invalid_operation(
"public link server proof did not match; refusing the session",
));
}
let share_key = self.unlock_share_key(&response).await?;
// An absent AccessToken means the server accepted an existing Proton
// session instead of minting an anonymous one. We have no such session
// to fall back on, so it is an error rather than a silent no-auth state.
let access_token = response.access_token.clone().ok_or_else(|| {
ProtonError::invalid_operation("public link auth returned no access token")
})?;
let http = ApiHttpClient::new(
self.config.clone(),
SessionId::from(response.uid.clone()),
Tokens {
access_token,
// An anonymous public-link session cannot be refreshed; it is
// re-established by replaying the handshake instead.
refresh_token: String::new(),
},
)?
// Public-link data requests take the `drive/unauth/` prefix, not the
// `drive/` one an authenticated client uses (TS `getUnauthEndpoint`).
// The endpoints and payloads are otherwise identical; only this prefix
// tells the API to accept a session that has no `full`/`nondelinquent`
// scope. Sending them to `drive/` returns 403 `MissingScopes`.
// The session routes (`drive/urls/{token}/…`) are exempt — they run
// unauthenticated, before this client exists.
.with_base_route("drive/unauth/")
// The refresh token above is empty, so `ApiHttpClient`'s built-in 401
// handling would POST it to `auth/v4/refresh` and replace the real 401
// with an error about refreshing. Opt out, and let the 401 reach
// `RevisionTransport`, which recovers by replaying *this* handshake.
.without_token_refresh();
Ok(AuthOutcome {
http,
share_key,
root_uid: NodeUid::new(
response.share.volume_id.clone(),
response.share.link_id.clone(),
),
public_role: MemberRole::from_permissions(response.share.public_permissions),
})
}
/// Recover the share private key from the link password.
///
/// TS `SharingPublicSessionManager.decryptShareKey` →
/// `DriveCrypto.decryptKeyWithSrpPassword`. The salt is a bcrypt salt, not
/// the SRP one: the same `derive_key_passphrase` the create side used to
/// wrap this passphrase.
/// Runs on the blocking pool: the bcrypt derivation and the share key's S2K
/// are each tens of milliseconds of CPU.
async fn unlock_share_key(&self, response: &PublicLinkAuthResponse) -> Result<PrivateKey> {
let salt = decode_base64(&response.share.share_password_salt, "SharePasswordSalt")?;
let password = self.password.clone();
let armored_passphrase = response.share.share_passphrase.clone();
let armored_share_key = response.share.share_key.clone();
blocking(move || {
let key_password = derive_key_passphrase(password.as_bytes(), &salt)?;
let passphrase = decrypt_armored_with_password(&armored_passphrase, &key_password)?;
Ok(PrivateKey::from_armored(&armored_share_key, &passphrase)?)
})
.await
}
}
/// Run CPU-bound crypto off the runtime, mapping a join failure into a
/// [`ProtonError`]. The public-link handshake is the one path in the crate whose
/// crypto is not already behind `crypto::blocking` (that helper is about link
/// decryption and takes an `AccountClient`, which a visitor has no equivalent of).
async fn blocking<T, F>(work: F) -> Result<T>
where
F: FnOnce() -> Result<T> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work).await.map_err(|e| {
ProtonError::invalid_operation(format!("public link crypto task failed: {e}"))
})?
}
/// The client a public-link session is currently issuing requests through,
/// tagged with the handshake that produced it.
struct LiveSession {
http: ApiHttpClient,
generation: u64,
}
/// An authenticated, anonymous session against one public link.
///
/// Mirrors TS `SharingPublicLinkSession`. Holds the link password so an expired
/// session can be re-established by replaying the handshake — which matters more
/// here than anywhere else in the SDK, because the anonymous session is minted
/// with an **empty refresh token** and so cannot renew itself the ordinary way.
/// A long read (a film, say) will outlive it.
///
/// The live client is swapped behind a lock rather than handed back to the
/// caller as a new object, so every [`RevisionReader`](crate::RevisionReader)
/// already opened over this session keeps working across the swap. Swapping the
/// *whole* client is required, not just its tokens: a re-auth mints a new
/// `x-pm-uid` as well as a new bearer, and an [`ApiHttpClient`] binds one
/// session id for its lifetime.
struct PublicLinkSession {
credentials: Credentials,
/// Read on every request, written only by a handshake. A `std` lock because
/// [`BlockSession::http`] is synchronous; it is never held across an await,
/// and never poisoned in practice, so poisoning is recovered from rather
/// than propagated.
live: std::sync::RwLock<LiveSession>,
/// Serializes handshakes, so a burst of expired-session failures replays one
/// SRP exchange rather than one per caller.
renewing: tokio::sync::Mutex<()>,
}
impl PublicLinkSession {
/// Authenticate and wrap the result in a renewable session.
///
/// Returns the outcome alongside the session because the share key, root uid
/// and role belong to the *link*, not to the session, and the client keeps
/// them directly.
async fn open(credentials: Credentials) -> Result<(Arc<Self>, AuthOutcome)> {
let auth = credentials.auth().await?;
let session = Arc::new(Self {
credentials,
live: std::sync::RwLock::new(LiveSession {
http: auth.http.clone(),
generation: 0,
}),
renewing: tokio::sync::Mutex::new(()),
});
Ok((session, auth))
}
fn read_live<T>(&self, f: impl FnOnce(&LiveSession) -> T) -> T {
let live = self
.live
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(&live)
}
}
#[async_trait]
impl BlockSession for PublicLinkSession {
fn http(&self) -> (ApiHttpClient, u64) {
self.read_live(|live| (live.http.clone(), live.generation))
}
async fn renew(&self, seen: u64) -> Result<()> {
let _guard = self.renewing.lock().await;
if self.read_live(|live| live.generation) != seen {
// Someone else re-authenticated while we waited; their session is at
// least as fresh as one we would mint now.
return Ok(());
}
let auth = self.credentials.auth().await?;
let mut live = self
.live
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
live.http = auth.http;
live.generation += 1;
Ok(())
}
}
/// Read access to one public share link, as a visitor.
///
/// Obtained from [`open`](Self::open). The client holds an anonymous session and
/// the link's share key; every operation is scoped to the shared subtree.
///
/// Cloning is cheap and shares the session, so a clone recovers from an expiry
/// the original recovered from, and vice versa.
#[derive(Clone)]
pub struct ProtonDrivePublicLinkClient {
session: Arc<PublicLinkSession>,
/// Every request goes through here rather than through a bare
/// [`ApiHttpClient`], so an expired anonymous session is replayed once and
/// retried instead of surfacing as a 401.
transport: RevisionTransport,
/// The link's share key — the parent key of the shared root node.
share_key: PrivateKey,
root_uid: NodeUid,
public_role: MemberRole,
/// Unlocked node keys, so a folder's children do not each re-derive their
/// shared parent's key.
///
/// Unlocking a key is an S2K derivation — tens of milliseconds. Listing a
/// folder of 500 episodes walked the ancestor chain and re-unlocked every
/// key on it *per child*, which is the difference between a catalog crawl
/// taking seconds and taking minutes.
node_keys: Arc<std::sync::Mutex<LruCache<NodeUid, PrivateKey>>>,
/// Collapses concurrent misses for the same key onto one load, so the
/// fanned-out detail fetches below do not each start the same ancestor walk
/// before any of them has populated the cache.
key_loads: Arc<SingleFlight<NodeUid, PrivateKey>>,
}
/// Unlocked node keys held per client.
///
/// A shared subtree's *distinct* keys are its folders, so this only has to
/// outlive a crawl of one directory's worth of ancestors; it is a bound on
/// pathological trees, not a working-set estimate.
const NODE_KEY_CACHE_CAPACITY: usize = 512;
impl ProtonDrivePublicLinkClient {
/// Read a link's public metadata without opening it.
///
/// Use this to discover [`is_custom_password_protected`](PublicLinkInfo::is_custom_password_protected)
/// before prompting for a password. `url` is the full share URL including
/// its `#password` fragment.
pub async fn info(config: ProtonClientConfiguration, url: &str) -> Result<PublicLinkInfo> {
let (token, password) = parse_public_link_url(url)?;
let credentials = Credentials {
config,
token,
password,
};
let raw = credentials.info().await?;
Ok(PublicLinkInfo {
is_custom_password_protected: raw.is_custom_password_protected(),
is_legacy: raw.is_legacy(),
vendor_type: raw.vendor_type,
})
}
/// Authenticate against a public link and open it for reading.
///
/// `url` is the full share URL including its `#password` fragment (the form
/// [`PublicLink::url`](crate::PublicLink::url) hands back).
/// `custom_password` is required exactly when
/// [`info`](Self::info) reports the link is custom-password protected;
/// supplying the wrong one fails the SRP handshake, not the decryption.
pub async fn open(
config: ProtonClientConfiguration,
url: &str,
custom_password: Option<&str>,
) -> Result<Self> {
let (token, url_password) = parse_public_link_url(url)?;
let password = match custom_password {
Some(custom) if !custom.is_empty() => format!("{url_password}{custom}"),
_ => url_password,
};
let (session, auth) = PublicLinkSession::open(Credentials {
config,
token,
password,
})
.await?;
let transport = RevisionTransport::new(
session.clone(),
Arc::new(Semaphore::new(DEFAULT_MAX_INFLIGHT_BLOCKS)),
);
Ok(Self {
session,
transport,
share_key: auth.share_key,
root_uid: auth.root_uid,
public_role: auth.public_role,
node_keys: Arc::new(std::sync::Mutex::new(LruCache::new(
NonZeroUsize::new(NODE_KEY_CACHE_CAPACITY).expect("capacity is non-zero"),
))),
key_loads: Arc::new(SingleFlight::default()),
})
}
/// Cap the content blocks this client keeps in memory at once, across every
/// read it is running.
///
/// Mirrors
/// [`ProtonDriveClient::with_max_inflight_blocks`](crate::ProtonDriveClient::with_max_inflight_blocks).
/// The default is sized for a background sync; a player that seeks wants it
/// higher. Must be non-zero; a zero cap would deadlock every read, so it is
/// clamped to 1.
///
/// Applies to this client and every clone made from it *afterwards*; clones
/// already taken keep the previous cap.
pub fn with_max_inflight_blocks(mut self, blocks: usize) -> Self {
self.transport = RevisionTransport::new(
self.session.clone(),
Arc::new(Semaphore::new(blocks.max(1))),
);
self
}
/// The role the link grants its visitors — [`MemberRole::Viewer`] for a
/// read-only link, [`MemberRole::Editor`] for one that permits uploads.
pub fn public_role(&self) -> MemberRole {
self.public_role
}
/// The uid of the shared node: the folder or file the link points at.
pub fn root_uid(&self) -> &NodeUid {
&self.root_uid
}
/// The shared node itself, decrypted.
///
/// A link may point at a single file as easily as at a folder — check
/// [`Node::is_folder`] before listing children.
pub async fn get_root_node(&self) -> Result<Node> {
self.get_node(&self.root_uid.clone())
.await?
.ok_or_else(|| ProtonError::invalid_operation("the shared node no longer exists"))
}
/// Fetch one node inside the shared subtree.
///
/// Returns `Ok(None)` when the node does not exist. Nodes outside the shared
/// subtree are not readable — the server rejects them, and their parent key
/// is not derivable from the share key regardless.
pub async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
let mut nodes = self.enumerate_nodes(std::slice::from_ref(uid)).await?;
Ok(nodes.pop())
}
/// Enumerate the [`NodeUid`]s of a shared folder's children.
///
/// Same paging contract as
/// [`ProtonDriveClient::enumerate_folder_children_node_uids`](crate::ProtonDriveClient::enumerate_folder_children_node_uids):
/// uids only, materialized on demand via [`enumerate_nodes`](Self::enumerate_nodes).
pub async fn enumerate_folder_children_node_uids(
&self,
folder_uid: &NodeUid,
) -> Result<Vec<NodeUid>> {
let mut uids = Vec::new();
let mut anchor: Option<LinkId> = None;
loop {
let mut path = format!(
"v2/volumes/{}/folders/{}/children",
folder_uid.volume_id, folder_uid.link_id
);
if let Some(anchor_id) = &anchor {
path.push_str(&format!("?AnchorID={anchor_id}"));
}
let page: FolderChildrenResponse = self.transport.get(&path).await?;
if page.link_ids.is_empty() {
break;
}
for link_id in page.link_ids {
uids.push(NodeUid::new(folder_uid.volume_id.clone(), link_id));
}
if !page.more_results_exist {
break;
}
anchor = page.anchor_id;
if anchor.is_none() {
break;
}
}
Ok(uids)
}
/// Fetch decrypted metadata for many nodes in the shared subtree.
///
/// A node that does not exist is omitted; one that fails to decrypt is
/// logged and skipped, so the result may be shorter than `uids` — the same
/// partial-listing behavior as the authenticated client.
///
/// Link ids are batched at [`MAX_BATCH_COUNT`] per request — the server's
/// limit, which an unchunked call silently exceeded on any folder past 150
/// children — and the batches are fetched several at a time.
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let mut nodes = Vec::with_capacity(uids.len());
for (volume_id, link_ids) in group_by_volume(uids) {
// The closure takes its batch *by value*. Taking `&[LinkId]` — which
// `chunks()` hands out directly — would give the fetch future a
// higher-ranked lifetime that `tokio::spawn` rejects in callers
// ("implementation of `FnOnce` is not general enough"), an error that
// surfaces only downstream. `tests/spawnable.rs` pins it.
let chunks: Vec<Vec<LinkId>> = link_ids
.chunks(MAX_BATCH_COUNT)
.map(<[LinkId]>::to_vec)
.collect();
let batches = chunks.into_iter().map(|link_ids| {
let client = self.clone();
let volume_id = volume_id.clone();
async move { client.fetch_link_details(&volume_id, &link_ids).await }
});
// `buffered` yields in request order, so a listing keeps the order
// its uids were given in.
let mut responses = stream::iter(batches).buffered(MAX_CONCURRENT_DETAIL_FETCHES);
while let Some(response) = responses.try_next().await? {
for details in &response.links {
let parent_key = match self.resolve_parent_key(&volume_id, details).await {
Ok(key) => key,
Err(e) => {
tracing::warn!(link_id = %details.link.id, "skipping node: {e}");
continue;
}
};
match self.build_node(&volume_id, details, &parent_key) {
Ok(node) => nodes.push(node),
Err(e) => {
tracing::warn!(link_id = %details.link.id, "skipping node: {e}")
}
}
}
}
}
Ok(nodes)
}
/// One batch of link details. Callers must keep `link_ids` within
/// [`MAX_BATCH_COUNT`].
async fn fetch_link_details(
&self,
volume_id: &VolumeId,
link_ids: &[LinkId],
) -> Result<LinkDetailsResponse> {
self.transport
.post(
&format!("v2/volumes/{volume_id}/links"),
&LinkDetailsRequest { link_ids },
)
.await
}
/// Download a shared file's active revision.
///
/// Unlike the authenticated client's
/// [`download_file`](crate::ProtonDriveClient::download_file), the content
/// manifest is **not** verified: doing so needs the uploader's address keys,
/// which an anonymous visitor cannot fetch. The bytes are decrypted with the
/// file's content key, so they are still authentic to whoever holds the node
/// key — but nothing here attests *who* that was.
pub async fn download_file(&self, uid: &NodeUid) -> Result<Vec<u8>> {
let mut out = Vec::new();
self.download_file_to(uid, &mut out).await?;
Ok(out)
}
/// As [`download_file`](Self::download_file), streaming into `writer`.
pub async fn download_file_to<W: std::io::Write>(
&self,
uid: &NodeUid,
writer: &mut W,
) -> Result<()> {
let details = self.link_details(uid).await?;
let revision_id = file_properties(&details)
.and_then(|file| file.active_revision.as_ref())
.map(|revision| revision.id.clone())
.ok_or_else(|| {
ProtonError::invalid_operation("the shared file has no active revision")
})?;
let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?;
let decrypted = decrypt_link(&parent_key, &details.link)?;
let content_key = self.content_key(&details, &decrypted.node_key)?;
// Paged: a single un-paged GET returns only the first 50 blocks, which
// silently truncated any file past 200 MiB.
let (_, blocks) = self
.transport
.list_blocks(&uid.volume_id, &uid.link_id, &revision_id)
.await?;
// Fetched and decrypted several at a time but yielded in block order, so
// the writer still sees the file sequentially and no more than
// `MAX_CONCURRENT_BLOCK_DOWNLOADS` blocks are ever resident.
let mut plaintexts = stream::iter(blocks.into_iter().map(|block| {
let transport = self.transport.clone();
let content_key = content_key.clone();
async move {
let permit = transport.block_slots().acquire_owned().await.map_err(|e| {
ProtonError::invalid_operation(format!("block slots closed: {e}"))
})?;
let ciphertext = transport
.http()
.get_storage_blob(&block.bare_url, &block.token)
.await?;
let plaintext = decrypt_block_blocking(content_key, ciphertext).await?;
Ok::<_, ProtonError>((plaintext, permit))
}
}))
.buffered(MAX_CONCURRENT_BLOCK_DOWNLOADS);
// `_permit` frees once the block is written, not when it is decrypted.
while let Some((plaintext, _permit)) = plaintexts.try_next().await? {
writer
.write_all(&plaintext)
.map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?;
}
Ok(())
}
/// Download and decrypt only the plaintext byte range
/// `[offset, offset + length)` of a shared file's active revision.
///
/// A one-shot convenience over [`open_revision`](Self::open_revision), which
/// callers issuing more than one read should hold instead — this repeats the
/// whole resolution (link details, ancestor walk, node-key unlock, revision
/// listing) every time.
pub async fn download_range(&self, uid: &NodeUid, offset: u64, length: u64) -> Result<Vec<u8>> {
self.open_revision(uid).await?.read_at(offset, length).await
}
/// Open the active revision of a shared file for seekable reads.
///
/// This is what makes a public link streamable: every content block is a
/// self-contained PGP packet under one session key, so a
/// [`RevisionReader`] can serve any byte range by fetching only the blocks
/// that overlap it, several at a time — no whole-file download, no
/// sequential decrypt.
///
/// The reader holds this client's session and renews it on expiry, so it
/// keeps working past the anonymous session's lifetime.
///
/// The reader is pinned to the revision that is active now; it does not
/// follow later revisions of the same file.
pub async fn open_revision(&self, uid: &NodeUid) -> Result<RevisionReader> {
self.open_revision_inner(uid, None).await
}
/// As [`open_revision`](Self::open_revision), on an explicit revision.
///
/// A visitor cannot enumerate a file's revision history, so `revision_id`
/// has to come from somewhere that already knew it —
/// [`NodeKind::File::active_revision_id`](crate::NodeKind) recorded earlier,
/// typically, to reopen the same bytes a previous session was reading.
pub async fn open_revision_at(
&self,
uid: &NodeUid,
revision_id: &str,
) -> Result<RevisionReader> {
self.open_revision_inner(uid, Some(revision_id)).await
}
async fn open_revision_inner(
&self,
uid: &NodeUid,
revision_id: Option<&str>,
) -> Result<RevisionReader> {
let details = self.link_details(uid).await?;
let active = file_properties(&details).and_then(|file| file.active_revision.as_ref());
let revision_id = match revision_id {
Some(id) => id.to_string(),
None => active.map(|revision| revision.id.clone()).ok_or_else(|| {
ProtonError::invalid_operation("the shared file has no active revision")
})?,
};
let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?;
let node_key = decrypt_link(&parent_key, &details.link)?.node_key;
let content_key = self.content_key(&details, &node_key)?;
let (revision, blocks) = self
.transport
.list_blocks(&uid.volume_id, &uid.link_id, &revision_id)
.await?;
// Prefer the revision's own extended attributes. Fall back to the copy
// the link-details response above already delivered for the *active*
// revision — the visitor route has a history of stripping fields the
// authenticated one returns, and without a usable size the reader cannot
// place block boundaries and refuses to open a multi-block file at all.
let armored = revision.extended_attributes.as_deref().or_else(|| {
active
.filter(|candidate| candidate.id == revision_id)
.and_then(|candidate| candidate.extended_attributes.as_deref())
});
let common = armored.and_then(|xattr| {
match decrypt_extended_attributes_unverified(&node_key, xattr) {
Ok(attrs) => attrs.common,
Err(e) => {
tracing::debug!(error = %e, "revision extended attributes did not decrypt");
None
}
}
});
let block_sizes = rank_block_sizes(common.as_ref(), &revision_id, blocks.len())?;
Ok(RevisionReader::new(
self.transport.clone(),
uid.clone(),
revision_id,
content_key,
blocks,
block_sizes,
))
}
/// Download and decrypt a shared file's thumbnail of the given type, if it
/// has one.
///
/// Ports the authenticated
/// [`download_thumbnail`](crate::ProtonDriveClient::download_thumbnail): the
/// revision's thumbnail header names a block id, `POST volumes/{vid}/thumbnails`
/// resolves it to a download URL, and the bytes decrypt under the same
/// content key as the file's content blocks. Only the route prefix differs.
/// Returns `Ok(None)` when the file has no thumbnail of that type.
pub async fn download_thumbnail(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>> {
let (content_key, thumbnail_id) = match self.thumbnail_target(uid, thumbnail_type).await? {
(content_key, Some(id)) => (content_key, id),
(_, None) => return Ok(None),
};
let response: ThumbnailBlockListResponse = self
.transport
.post(
&format!("volumes/{}/thumbnails", uid.volume_id),
&ThumbnailBlockListRequest {
thumbnail_ids: vec![thumbnail_id.clone()],
},
)
.await?;
let block = response
.blocks
.into_iter()
.find(|block| block.thumbnail_id == thumbnail_id)
.ok_or_else(|| {
let reason = response
.errors
.iter()
.find(|e| e.thumbnail_id == thumbnail_id)
.map(|e| e.error.clone())
.unwrap_or_else(|| "thumbnail block not returned".to_string());
ProtonError::invalid_operation(format!("resolve thumbnail block: {reason}"))
})?;
let ciphertext = self
.transport
.http()
.get_storage_blob(&block.bare_url, &block.token)
.await?;
Ok(Some(content_key.decrypt_thumbnail(&ciphertext)?))
}
/// Batch-download the thumbnails of `uids`.
///
/// The shape a poster wall wants: block ids are resolved in batches of up to
/// [`MAX_THUMBNAIL_IDS_PER_REQUEST`], and a per-file failure — no thumbnail
/// of that type, a decrypt error, a node that is not a file — is reported in
/// that file's [`FileThumbnail`] instead of failing the batch. Returned
/// order does not track the input order.
pub async fn enumerate_thumbnails(
&self,
uids: &[NodeUid],
thumbnail_type: ThumbnailType,
) -> Result<Vec<FileThumbnail>> {
let mut results: Vec<FileThumbnail> = Vec::new();
for (volume_id, link_ids) in group_by_volume(uids) {
// thumbnail id -> the file it belongs to and the key it decrypts under.
let mut targets: HashMap<String, (NodeUid, ContentKey)> = HashMap::new();
for link_id in link_ids {
let uid = NodeUid::new(volume_id.clone(), link_id);
match self.thumbnail_target(&uid, thumbnail_type).await {
Ok((content_key, Some(id))) => {
targets.insert(id, (uid, content_key));
}
Ok((_, None)) => results.push(FileThumbnail::err(
uid.clone(),
ProtonError::invalid_operation(format!(
"node {uid} has no thumbnail of the requested type"
)),
)),
Err(e) => results.push(FileThumbnail::err(uid, e)),
}
}
let thumbnail_ids: Vec<String> = targets.keys().cloned().collect();
for chunk in thumbnail_ids.chunks(MAX_THUMBNAIL_IDS_PER_REQUEST) {
self.drain_thumbnail_chunk(&volume_id, chunk, &mut targets, &mut results)
.await;
}
}
Ok(results)
}
/// Resolve one chunk of thumbnail ids to blocks, download them, and record
/// an outcome for every id in the chunk — including the ones the server said
/// nothing about at all.
async fn drain_thumbnail_chunk(
&self,
volume_id: &VolumeId,
chunk: &[String],
targets: &mut HashMap<String, (NodeUid, ContentKey)>,
results: &mut Vec<FileThumbnail>,
) {
let response: ThumbnailBlockListResponse = match self
.transport
.post(
&format!("volumes/{volume_id}/thumbnails"),
&ThumbnailBlockListRequest {
thumbnail_ids: chunk.to_vec(),
},
)
.await
{
Ok(response) => response,
// The chunk request itself failed: every file in it gets that error.
Err(e) => {
for id in chunk {
if let Some((uid, _)) = targets.remove(id) {
results.push(FileThumbnail::err(
uid,
ProtonError::invalid_operation(format!(
"resolve thumbnail blocks: {e}"
)),
));
}
}
return;
}
};
for block in response.blocks {
let Some((uid, content_key)) = targets.remove(&block.thumbnail_id) else {
continue;
};
let downloaded = match self
.transport
.http()
.get_storage_blob(&block.bare_url, &block.token)
.await
{
Ok(ciphertext) => content_key
.decrypt_thumbnail(&ciphertext)
.map_err(ProtonError::from),
Err(e) => Err(e),
};
results.push(match downloaded {
Ok(bytes) => FileThumbnail::ok(uid, bytes),
Err(e) => FileThumbnail::err(uid, e),
});
}
for error in response.errors {
if let Some((uid, _)) = targets.remove(&error.thumbnail_id) {
results.push(FileThumbnail::err(
uid,
ProtonError::invalid_operation(error.error),
));
}
}
// Anything still in `targets` was neither returned nor errored on. Left
// unreported it would look like a file that was never asked about.
for id in chunk {
if let Some((uid, _)) = targets.remove(id) {
results.push(FileThumbnail::err(
uid,
ProtonError::invalid_operation("thumbnail not found".to_string()),
));
}
}
}
/// A file's content key and the block id of its thumbnail of
/// `thumbnail_type`, if it has one.
async fn thumbnail_target(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<(ContentKey, Option<String>)> {
let details = self.link_details(uid).await?;
let revision_id = file_properties(&details)
.and_then(|file| file.active_revision.as_ref())
.map(|revision| revision.id.clone())
.ok_or_else(|| {
ProtonError::invalid_operation("the shared file has no active revision")
})?;
let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?;
let node_key = decrypt_link(&parent_key, &details.link)?.node_key;
let content_key = self.content_key(&details, &node_key)?;
// The thumbnail headers ride along with the revision's block listing.
let (revision, _blocks) = self
.transport
.list_blocks(&uid.volume_id, &uid.link_id, &revision_id)
.await?;
let wanted = thumbnail_type.as_i32();
let thumbnail_id = revision
.thumbnails
.iter()
.find(|thumbnail| thumbnail.thumbnail_type == wanted)
.and_then(|thumbnail| thumbnail.id.clone());
Ok((content_key, thumbnail_id))
}
/// Re-run the handshake, replacing the session behind this client.
///
/// The anonymous session carries no refresh token, so an expiry is recovered
/// from by authenticating again (TS `SharingPublicLinkSession.reauth`).
///
/// Rarely needed directly: every request this client makes already replays
/// the handshake once on an expired session, and so does every
/// [`RevisionReader`](crate::RevisionReader) it hands out. Reach for it to
/// renew *ahead* of a long read rather than to repair a failed one.
///
/// The share key, root uid and role belong to the link and cannot change
/// under a successful re-auth, so only the session is replaced.
pub async fn refresh_session(&self) -> Result<()> {
let (_, generation) = self.session.http();
self.session.renew(generation).await
}
/// As [`refresh_session`](Self::refresh_session), returning a client for
/// callers written against the older shape.
///
/// The returned client shares this one's session rather than owning a
/// separate one — which is the point of the change: readers opened from
/// *either* handle recover from the expiry, where previously the original
/// client and everything it had handed out were stranded.
pub async fn reauth(&self) -> Result<Self> {
self.refresh_session().await?;
Ok(self.clone())
}
/// The key that decrypts `link`: the share key at the shared root, otherwise
/// the parent node's key.
///
/// The walk terminates at the shared root because the visitor cannot see
/// past it — a link whose parent chain does not reach the root is not
/// readable with this session's material.
async fn resolve_parent_key(
&self,
volume_id: &VolumeId,
details: &LinkDetailsDto,
) -> Result<PrivateKey> {
if details.link.id == self.root_uid.link_id {
return Ok(self.share_key.clone());
}
let parent_id = details.link.parent_id.clone().ok_or_else(|| {
ProtonError::invalid_operation("node has no parent and is not the shared root")
})?;
self.node_key(&NodeUid::new(volume_id.clone(), parent_id))
.await
}
/// A node's own unlocked key, from cache when it is there.
///
/// Walks *up* to the nearest cached ancestor (or the shared root, whose
/// parent key is the share key), then unlocks back *down*, caching every key
/// on the way. Written iteratively rather than recursively so it is one
/// finite future — an `async fn` that awaited itself would need boxing at
/// the recursion point, and its `Send`-ness would depend on its own, which
/// the compiler cannot resolve.
async fn node_key(&self, uid: &NodeUid) -> Result<PrivateKey> {
if let Some(key) = self.cached_node_key(uid) {
return Ok(key);
}
let client = self.clone();
let target = uid.clone();
self.key_loads
.run(uid.clone(), async move {
// Up: collect the chain from `target` towards the root, stopping
// at the first key already unlocked.
let mut chain: Vec<LinkDetailsDto> = Vec::new();
let mut cursor = target.clone();
let mut key = loop {
if let Some(key) = client.cached_node_key(&cursor) {
break key;
}
let details = client.link_details(&cursor).await?;
if details.link.id == client.root_uid.link_id {
let key = decrypt_link(&client.share_key, &details.link)?.node_key;
client.cache_node_key(&cursor, &key);
break key;
}
let parent_id = details.link.parent_id.clone().ok_or_else(|| {
ProtonError::invalid_operation(
"node has no parent and is not the shared root",
)
})?;
let parent = NodeUid::new(cursor.volume_id.clone(), parent_id);
chain.push(details);
cursor = parent;
};
// Down: each node's key unlocks the next, and every one is worth
// keeping — the siblings still to be listed will ask for them.
while let Some(details) = chain.pop() {
key = decrypt_link(&key, &details.link)?.node_key;
client.cache_node_key(
&NodeUid::new(target.volume_id.clone(), details.link.id.clone()),
&key,
);
}
Ok(key)
})
.await
}
fn cached_node_key(&self, uid: &NodeUid) -> Option<PrivateKey> {
self.node_keys
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(uid)
.cloned()
}
fn cache_node_key(&self, uid: &NodeUid, key: &PrivateKey) {
self.node_keys
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.put(uid.clone(), key.clone());
}
async fn link_details(&self, uid: &NodeUid) -> Result<LinkDetailsDto> {
let response = self
.fetch_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
.await?;
response
.links
.into_iter()
.next()
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} was not found")))
}
/// Unwrap a file's content key from its `ContentKeyPacket`.
fn content_key(&self, details: &LinkDetailsDto, node_key: &PrivateKey) -> Result<ContentKey> {
let packet = file_properties(details)
.and_then(|file| file.content_key_packet.as_ref())
.ok_or_else(|| ProtonError::invalid_operation("the file has no content key packet"))?;
let packet_bytes = decode_base64(packet, "ContentKeyPacket")?;
Ok(node_key.decrypt_content_key(&packet_bytes)?)
}
/// Build a public [`Node`] from a decrypted link.
///
/// Verification is left at its default (nothing claimed) — see the module
/// note on why an anonymous visitor cannot check authorship.
fn build_node(
&self,
volume_id: &VolumeId,
details: &LinkDetailsDto,
parent_key: &PrivateKey,
) -> Result<Node> {
let link = &details.link;
let decrypted = decrypt_link(parent_key, link)?;
let kind = if matches!(link.parsed_type(), LinkType::Folder) {
NodeKind::Folder
} else {
let file = file_properties(details);
let active = file.and_then(|f| f.active_revision.as_ref());
// The link-details response already carries the active revision's
// `XAttr`, so the uploader's claimed size, mtime and digest cost
// nothing beyond one decrypt — no extra round-trip. Unverified,
// because a visitor has no address book to resolve the signer
// through; see the module note.
let common = active
.and_then(|revision| revision.extended_attributes.as_deref())
.and_then(|xattr| {
match decrypt_extended_attributes_unverified(&decrypted.node_key, xattr) {
Ok(attrs) => attrs.common,
// Present but unreadable is worth a line — absent is
// ordinary, and says nothing.
Err(e) => {
tracing::debug!(link_id = %link.id, error = %e,
"revision extended attributes did not decrypt");
None
}
}
});
NodeKind::File {
media_type: file.map(|f| f.media_type.clone()).unwrap_or_default(),
total_size_on_storage: file.map(|f| f.total_size_on_storage).unwrap_or_default(),
active_revision_state: active
.map(|revision| RevisionState::from_raw(revision.state)),
active_revision_id: active.map(|revision| revision.id.clone()),
claimed_size: common.as_ref().and_then(|c| c.size),
claimed_modification_time: common
.as_ref()
.and_then(|c| c.modification_time.clone()),
content_sha1: common
.as_ref()
.and_then(|c| c.digests.as_ref())
.and_then(|d| d.sha1.clone()),
}
};
Ok(Node {
uid: NodeUid::new(volume_id.clone(), link.id.clone()),
parent_uid: link
.parent_id
.clone()
.map(|parent| NodeUid::new(volume_id.clone(), parent)),
kind,
name: decrypted.name,
creation_time: link.creation_time,
modification_time: link.modification_time,
trashed: link.is_trashed(),
is_shared: true,
is_shared_publicly: true,
signature_email: link.name_signature_email.clone(),
// A public-link visitor has no account membership — access comes
// from the link's own share password, not from a share member row.
membership: None,
// A public link points at a Drive folder or file; the photos volume
// is not reachable this way, so there is no photo/album metadata.
photo: None,
album: None,
verification: Default::default(),
})
}
}
/// A node's file properties, wherever the server chose to put them.
///
/// The photos volume returns them under `Photo` rather than `File`
/// (C# `linkDetailsDto.File ?? linkDetailsDto.Photo`) — a public link may point
/// into either.
fn file_properties(details: &LinkDetailsDto) -> Option<&FileDto> {
details
.file
.as_ref()
.or_else(|| details.photo.as_ref().map(|photo| &photo.file))
}
fn decode_base64(value: &str, what: &str) -> Result<Vec<u8>> {
BASE64
.decode(value)
.map_err(|e| ProtonError::invalid_operation(format!("{what} is not valid base64: {e}")))
}
/// Group uids by volume, preserving first-seen order.
fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec<LinkId>)> {
let mut order: Vec<VolumeId> = Vec::new();
let mut groups: HashMap<VolumeId, Vec<LinkId>> = HashMap::new();
for uid in uids {
let entry = groups.entry(uid.volume_id.clone()).or_insert_with(|| {
order.push(uid.volume_id.clone());
Vec::new()
});
entry.push(uid.link_id.clone());
}
order
.into_iter()
.map(|volume_id| {
let link_ids = groups.remove(&volume_id).unwrap_or_default();
(volume_id, link_ids)
})
.collect()
}
#[cfg(test)]
mod tests {
use crate::client::parse_public_link_url;
use crate::dtos::PublicLinkInfoResponse;
fn info(flags: i32) -> PublicLinkInfoResponse {
PublicLinkInfoResponse {
version: 4,
modulus: String::new(),
server_ephemeral: String::new(),
url_password_salt: String::new(),
srp_session: String::new(),
flags,
vendor_type: 0,
}
}
#[test]
fn parses_token_and_password_from_a_share_url() {
let (token, password) =
parse_public_link_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap();
assert_eq!(token, "ABC123");
assert_eq!(password, "s3cr3t");
}
#[test]
fn tolerates_a_trailing_slash_before_the_fragment() {
let (token, password) =
parse_public_link_url("https://drive.proton.me/urls/ABC123/#s3cr3t").unwrap();
assert_eq!(token, "ABC123");
assert_eq!(password, "s3cr3t");
}
#[test]
fn rejects_urls_without_a_usable_secret() {
// No fragment at all: the URL carries no password to open anything with.
assert!(parse_public_link_url("https://drive.proton.me/urls/ABC123").is_err());
// Empty fragment, empty token.
assert!(parse_public_link_url("https://drive.proton.me/urls/ABC123#").is_err());
assert!(parse_public_link_url("https://drive.proton.me/urls/#s3cr3t").is_err());
}
#[test]
fn link_flags_classify_custom_password_and_legacy_links() {
// TS: isCustomPasswordProtected = (Flags & 1) === 1,
// isLegacy = Flags === 0 || Flags === 1.
assert!(info(0).is_legacy());
assert!(info(1).is_legacy());
assert!(!info(2).is_legacy());
assert!(!info(3).is_legacy());
assert!(!info(2).is_custom_password_protected());
assert!(info(3).is_custom_password_protected());
}
}