diff --git a/crates/proton-drive-sdk/src/client.rs b/crates/proton-drive-sdk/src/client.rs index 6320fc6..9e6475e 100644 --- a/crates/proton-drive-sdk/src/client.rs +++ b/crates/proton-drive-sdk/src/client.rs @@ -15,8 +15,8 @@ use proton_sdk::crypto::PrivateKey; use proton_sdk::error::{ProtonError, Result}; use proton_sdk::http::ApiHttpClient; use proton_sdk::crypto::{ - generate_node_hash_key, generate_node_key, verify_detached, ContentKey, VerificationKeyRing, - VerificationStatus, + build_volume_creation_material, generate_node_hash_key, generate_node_key, verify_detached, + ContentKey, VerificationKeyRing, VerificationStatus, }; use proton_sdk::ids::{AddressId, DriveEventId, LinkId, NodeUid, ShareId, VolumeId}; use proton_sdk::session::ProtonApiSession; @@ -40,7 +40,7 @@ use crate::dtos::{ RevisionCreationResponse, RevisionDto, RevisionResponse, RevisionUpdateRequest, LatestVolumeEventResponse, PhotosAttributesDto, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, - VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, + VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, }; use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem}; use crate::cache::DriveEntityCache; @@ -165,7 +165,7 @@ impl ProtonDriveClient { entity_repository: Arc, ) -> Self { Self { - http: session.http().clone(), + http: session.http().with_base_route("drive/"), account: AccountClient::new(session, mailbox_password), cache: Arc::new(Mutex::new(DriveCache::default())), entities: DriveEntityCache::new(entity_repository), @@ -1541,7 +1541,17 @@ impl ProtonDriveClient { return Ok(()); } - let response: MyFilesShareResponse = self.http.get("v2/shares/my-files").await?; + // Mirrors C# `NodeOperations.GetOrCreateMyFilesFolderAsync`: a brand-new + // account has no My Files volume yet, so the share lookup fails. Create + // the volume, then re-read it through the normal path to populate caches. + let response: MyFilesShareResponse = match self.http.get("v2/shares/my-files").await { + Ok(response) => response, + Err(e) if is_my_files_missing(&e) => { + self.create_volume().await?; + self.http.get("v2/shares/my-files").await? + } + Err(e) => return Err(e), + }; let volume_id = response.volume.id.clone(); let share_id = response.share.id.clone(); @@ -1566,6 +1576,42 @@ impl ProtonDriveClient { Ok(()) } + /// Create the account's main volume (root share + root folder). + /// + /// Mirrors C# `VolumeOperations.CreateVolumeAsync`: build the root share and + /// folder crypto material against the default address's primary key and + /// `POST volumes`. The server-side state is then read back by the caller + /// ([`ensure_my_files`](Self::ensure_my_files)) via the normal share lookup, + /// so no local cache priming is needed here. + async fn create_volume(&self) -> Result<()> { + let mut timer = self.telemetry.start("create_volume"); + + let address = self.account.default_address().await?; + let address_keys = self.account.address_private_keys(&address.id).await?; + let address_key = address_keys + .get(address.primary_key_index) + .ok_or_else(|| ProtonError::invalid_operation("default address has no primary key"))?; + + let material = build_volume_creation_material(address_key, "root")?; + + let request = VolumeCreationRequest { + address_id: address.id.clone(), + address_key_id: address.primary_key_id.clone(), + share_key: material.share_key_armored, + share_passphrase: material.share_passphrase, + share_passphrase_signature: material.share_passphrase_signature, + folder_name: material.folder_name, + folder_key: material.folder_key_armored, + folder_passphrase: material.folder_passphrase, + folder_passphrase_signature: material.folder_passphrase_signature, + folder_hash_key: material.folder_hash_key, + }; + + let _: proton_sdk::api::ApiResponse = self.http.post("volumes", &request).await?; + timer.success(); + Ok(()) + } + async fn root_share_key(&self) -> Result { self.ensure_my_files().await?; Ok(self @@ -2209,6 +2255,18 @@ fn read_full_block(reader: &mut R, buf: &mut [u8]) -> Result { /// Group node uids by volume, preserving order, so each volume's links are /// batched into a single request family (C# groups by `VolumeId`). +/// Whether a `v2/shares/my-files` error means the account has no My Files volume +/// yet (and so one must be created). C# catches [`ResponseCode::DoesNotExist`]; +/// in practice a fresh account's lookup also surfaces as a bare HTTP 404, so +/// both are treated as "missing". +fn is_my_files_missing(error: &ProtonError) -> bool { + matches!( + error, + ProtonError::Api(e) + if e.code == proton_sdk::api::ResponseCode::DoesNotExist || e.http_status == 404 + ) +} + fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec)> { let mut groups: Vec<(VolumeId, Vec)> = Vec::new(); for uid in uids { diff --git a/crates/proton-drive-sdk/src/dtos.rs b/crates/proton-drive-sdk/src/dtos.rs index f1ac4f6..35b2a14 100644 --- a/crates/proton-drive-sdk/src/dtos.rs +++ b/crates/proton-drive-sdk/src/dtos.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; -use proton_sdk::ids::{AddressId, DriveEventId, LinkId, ShareId, VolumeId}; +use proton_sdk::ids::{AddressId, AddressKeyId, DriveEventId, LinkId, ShareId, VolumeId}; /// `GET v2/shares/my-files` #[derive(Debug, Deserialize)] @@ -429,6 +429,33 @@ pub struct FolderCreationRequest { pub signature_email: String, } +/// `POST volumes` request body — create a new volume with its root share and +/// root folder. Mirrors C# `VolumeCreationRequest`. All PGP fields are armored +/// strings produced by [`proton_sdk::crypto::build_volume_creation_material`]. +#[derive(Debug, Serialize)] +pub struct VolumeCreationRequest { + #[serde(rename = "AddressID")] + pub address_id: AddressId, + #[serde(rename = "AddressKeyID")] + pub address_key_id: AddressKeyId, + #[serde(rename = "ShareKey")] + pub share_key: String, + #[serde(rename = "SharePassphrase")] + pub share_passphrase: String, + #[serde(rename = "SharePassphraseSignature")] + pub share_passphrase_signature: String, + #[serde(rename = "FolderName")] + pub folder_name: String, + #[serde(rename = "FolderKey")] + pub folder_key: String, + #[serde(rename = "FolderPassphrase")] + pub folder_passphrase: String, + #[serde(rename = "FolderPassphraseSignature")] + pub folder_passphrase_signature: String, + #[serde(rename = "FolderHashKey")] + pub folder_hash_key: String, +} + /// `POST v2/volumes/{vid}/folders` response. #[derive(Debug, Deserialize)] pub struct FolderCreationResponse { diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs index 8d701de..b7ca3d8 100644 --- a/crates/proton-sdk/src/account.rs +++ b/crates/proton-sdk/src/account.rs @@ -20,7 +20,7 @@ use tokio::sync::Mutex; use crate::crypto::{self, PrivateKey, PublicKey}; use crate::error::{ProtonError, Result}; use crate::http::ApiHttpClient; -use crate::ids::AddressId; +use crate::ids::{AddressId, AddressKeyId}; use crate::session::ProtonApiSession; use dtos::{ @@ -37,6 +37,8 @@ pub struct Address { pub status: i32, /// Index, within the decrypted key list, of the primary key. pub primary_key_index: usize, + /// Id of the address's primary key (the `AddressKeyID` for write requests). + pub primary_key_id: AddressKeyId, } /// Resolves account keys needed to decrypt Drive metadata. @@ -263,6 +265,7 @@ impl AccountClient { let mut keys = Vec::new(); let mut primary_key_index = None; + let mut primary_key_id = None; for key_dto in &dto.keys { if !key_dto.is_active() { @@ -295,6 +298,7 @@ impl AccountClient { Ok(key) => { if key_dto.is_primary() && primary_key_index.is_none() { primary_key_index = Some(keys.len()); + primary_key_id = Some(key_dto.id.clone()); } keys.push(key); } @@ -306,6 +310,8 @@ impl AccountClient { let primary_key_index = primary_key_index .ok_or_else(|| ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)))?; + let primary_key_id = primary_key_id + .ok_or_else(|| ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)))?; let address = Address { id: dto.id.clone(), @@ -313,6 +319,7 @@ impl AccountClient { order: dto.order, status: dto.status, primary_key_index, + primary_key_id, }; Ok((address, keys)) diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index 0154f3d..bbec312 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -16,7 +16,10 @@ mod verify; pub use content::ContentKey; pub use derive::derive_key_passphrase; -pub use encrypt::{generate_node_hash_key, generate_node_key, GeneratedNodeKey}; +pub use encrypt::{ + build_volume_creation_material, generate_node_hash_key, generate_node_key, GeneratedNodeKey, + VolumeCreationMaterial, +}; pub use errors::CryptoError; pub use keys::{decrypt_armored_with_keys, PrivateKey}; pub use srp::{generate_proofs, SrpProofs, DEFAULT_BIT_LENGTH}; diff --git a/crates/proton-sdk/src/crypto/encrypt.rs b/crates/proton-sdk/src/crypto/encrypt.rs index 692359a..c15b165 100644 --- a/crates/proton-sdk/src/crypto/encrypt.rs +++ b/crates/proton-sdk/src/crypto/encrypt.rs @@ -146,6 +146,76 @@ pub fn generate_node_hash_key(node_key: &PrivateKey) -> Result Result { + let share = generate_node_key()?; + let folder = generate_node_key()?; + + let share_passphrase = address_key.encrypt(&share.passphrase)?; + let share_passphrase_signature = address_key.sign_detached(&share.passphrase)?; + + let folder_name = share + .key + .encrypt_and_sign(address_key, root_folder_name.as_bytes(), true, false)?; + let folder_passphrase = share.key.encrypt(&folder.passphrase)?; + let folder_passphrase_signature = address_key.sign_detached(&folder.passphrase)?; + + let mut hash_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut hash_key); + let folder_hash_key = folder + .key + .encrypt_and_sign(address_key, &hash_key, false, false)?; + + Ok(VolumeCreationMaterial { + share_key_armored: share.locked_armored, + share_passphrase, + share_passphrase_signature, + folder_name, + folder_key_armored: folder.locked_armored, + folder_passphrase, + folder_passphrase_signature, + folder_hash_key, + }) +} + /// Encrypt `data` to `recipient`, optionally inline-signing with `signer`, and /// return the armored PGP message. Mirrors C# `PgpEncrypter.EncryptAndSign` /// (and the encrypt-only path when `signer` is `None`). @@ -260,6 +330,51 @@ mod tests { .expect("verify detached signature"); } + #[test] + fn volume_creation_material_round_trips() { + // Stand-in for the account's primary address key. + let address_key = generate_node_key().expect("generate address key"); + + let material = + build_volume_creation_material(&address_key.key, "root").expect("build material"); + + // The share passphrase is encrypted to the address key and unlocks the + // root share key; its detached signature verifies against the address key. + let share_pp = address_key + .key + .decrypt_armored_message(&material.share_passphrase) + .expect("decrypt share passphrase"); + address_key + .key + .verify_detached_signature(&material.share_passphrase_signature, &share_pp) + .expect("verify share passphrase signature"); + let share_key = PrivateKey::from_armored(&material.share_key_armored, &share_pp) + .expect("unlock share key"); + + // The folder passphrase is encrypted to the share key and unlocks the + // root folder key; its detached signature verifies against the address key. + let folder_pp = share_key + .decrypt_armored_message(&material.folder_passphrase) + .expect("decrypt folder passphrase"); + address_key + .key + .verify_detached_signature(&material.folder_passphrase_signature, &folder_pp) + .expect("verify folder passphrase signature"); + let folder_key = PrivateKey::from_armored(&material.folder_key_armored, &folder_pp) + .expect("unlock folder key"); + + // The folder name is encrypted to the share key; the hash key to the folder key. + let name = share_key + .decrypt_armored_message(&material.folder_name) + .expect("decrypt folder name"); + assert_eq!(name, b"root"); + + let hash_key = folder_key + .decrypt_armored_message(&material.folder_hash_key) + .expect("decrypt folder hash key"); + assert_eq!(hash_key.len(), 32); + } + #[test] fn node_hash_key_round_trips_under_node_key() { // The folder hash key is 32 random bytes encrypted to the folder's own diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 1caee7c..476a683 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -39,6 +39,12 @@ pub struct Tokens { #[derive(Clone)] pub struct ApiHttpClient { inner: Arc, + /// Extra path segment prepended to every request path (after `base_url`, + /// before the per-call `path`). Mirrors C# `session.GetHttpClient(baseRoute)` + /// — the Drive client targets `…/drive/` while account/auth calls stay at the + /// root. Empty by default. Lives on the outer struct (not `Inner`) so clones + /// can carry different prefixes while sharing one token/telemetry store. + route_prefix: Arc, } struct Inner { @@ -77,9 +83,22 @@ impl ApiHttpClient { tokens: Mutex::new(tokens), telemetry: std::sync::Mutex::new(NoopTelemetry::shared()), }), + route_prefix: Arc::from(""), }) } + /// Derive a clone that prepends `route` to every request path, sharing this + /// client's token store, telemetry sink and connection pool. Mirrors C# + /// `session.GetHttpClient(baseRoute)`: the Drive client passes `"drive/"` so + /// its routes resolve under `…/drive/` while auth/account calls (and token + /// refresh) stay at the root. `route` should end in `/`. + pub fn with_base_route(&self, route: impl Into>) -> Self { + Self { + inner: Arc::clone(&self.inner), + route_prefix: route.into(), + } + } + /// Snapshot the current tokens (e.g. to persist for a later `resume`). pub async fn current_tokens(&self) -> Tokens { self.inner.tokens.lock().await.clone() @@ -274,7 +293,12 @@ impl ApiHttpClient { body: Option<&B>, access_token: &str, ) -> Result { - let url = format!("{}{}", self.inner.base_url, path.trim_start_matches('/')); + let url = format!( + "{}{}{}", + self.inner.base_url, + self.route_prefix, + path.trim_start_matches('/') + ); send_retrying(&self.inner.config.retry_policy, || { let mut request = self .inner