diff --git a/Cargo.toml b/Cargo.toml index 9d84f05..1cf6dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ reqwest-middleware = "0.2.2" reqwest-retry = "0.2.2" # crypto -dco3_crypto = "0.5.0" +dco3_crypto = "0.5.1" # async runtime and utils tokio = { version = "1.29.1", features = ["full"] } diff --git a/src/auth/errors.rs b/src/auth/errors.rs index 777426b..f5993a5 100644 --- a/src/auth/errors.rs +++ b/src/auth/errors.rs @@ -1,14 +1,14 @@ use async_trait::async_trait; use dco3_crypto::DracoonCryptoError; -use reqwest_middleware::{Error as ReqError}; use reqwest::{Error as ClientError, Response}; +use reqwest_middleware::Error as ReqError; use thiserror::Error; use crate::{nodes::models::S3ErrorResponse, utils::FromResponse}; use super::models::{DracoonAuthErrorResponse, DracoonErrorResponse}; -#[derive(Debug, Error)] +#[derive(Debug, Error, PartialEq)] pub enum DracoonClientError { #[error("Client id required")] MissingClientId, @@ -44,33 +44,25 @@ pub enum DracoonClientError { impl From for DracoonClientError { fn from(value: ReqError) -> Self { - match value { - ReqError::Middleware(error) => { - DracoonClientError::ConnectionFailed - - }, + ReqError::Middleware(error) => DracoonClientError::ConnectionFailed, ReqError::Reqwest(error) => { if error.is_timeout() { - return DracoonClientError::ConnectionFailed + return DracoonClientError::ConnectionFailed; } if error.is_connect() { - return DracoonClientError::ConnectionFailed + return DracoonClientError::ConnectionFailed; } - DracoonClientError::Unknown - - }, + } } } } - impl From for DracoonClientError { fn from(value: ClientError) -> Self { - if value.is_timeout() { return DracoonClientError::ConnectionFailed; } @@ -83,8 +75,6 @@ impl From for DracoonClientError { } } - - #[async_trait] impl FromResponse for DracoonClientError { async fn from_response(value: Response) -> Result { @@ -145,7 +135,7 @@ impl DracoonClientError { _ => false, } } - + /// Check if the error is an 409 Conflict error pub fn is_conflict(&self) -> bool { match self { diff --git a/src/constants.rs b/src/constants.rs index b67924f..94daa7f 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -70,6 +70,10 @@ pub const GROUPS_LAST_ADMIN_ROOMS: &str = "last_admin_rooms"; pub const USERS_BASE: &str = "users"; pub const USERS_LAST_ADMIN_ROOMS: &str = "last_admin_rooms"; +// SETTINGS +pub const SETTINGS_BASE: &str = "settings"; +pub const SETTINGS_KEYPAIR: &str = "keypair"; + /// user agent header pub const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "|", env!("CARGO_PKG_VERSION")); diff --git a/src/lib.rs b/src/lib.rs index 615f9e2..e52986e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ //! Currently, the following traits are implemented: //! //! * [User] - for user account management -//! * [UserAccountKeypairs] - for user keypair management +//! * [UserAccountKeyPairs] - for user keypair management //! * [Nodes] - for node operations (folders, rooms, upload and download are excluded) //! * [Download] - for downloading files //! * [Upload] - for uploading files @@ -26,6 +26,7 @@ //! * [UploadShares] - for upload share operations //! * [Groups] - for group operations //! * [Users] - for user management operations +//! * [RescueKeyPair] - for distributing missing keys using the rescue key //! //! //! ### Example @@ -341,12 +342,13 @@ use self::{ // re-export traits and base models pub use self::{ nodes::{Download, Folders, Nodes, Rooms, Upload}, - user::{User, UserAccountKeypairs}, + user::{User, UserAccountKeyPairs}, auth::errors::DracoonClientError, auth::OAuth2Flow, groups::Groups, shares::{DownloadShares, UploadShares}, users::Users, + settings::RescueKeyPair, models::*, }; @@ -360,6 +362,7 @@ pub mod utils; pub mod groups; pub mod shares; pub mod users; +pub mod settings; mod tests; diff --git a/src/nodes/mod.rs b/src/nodes/mod.rs index 3490319..85a6ea5 100644 --- a/src/nodes/mod.rs +++ b/src/nodes/mod.rs @@ -1,11 +1,9 @@ //! This module implements a subset of the nodes DRACOON API. //! Documentation can be found here: -pub use self::{ - models::*, - rooms::models::*, -}; +pub use self::{models::*, rooms::models::*}; use super::{auth::errors::DracoonClientError, models::ListAllParams}; use async_trait::async_trait; + use std::io::Write; use tokio::io::{AsyncRead, BufReader}; @@ -16,11 +14,10 @@ pub mod nodes; pub mod rooms; pub mod upload; - /// This trait provides methods to manage nodes. -/// Specifically, there's a method to obtain a node for a given path and +/// Specifically, there's a method to obtain a node for a given path and /// all relevant methods to list nodes (get, search), move, copy and deleted nodes. -/// +/// /// To download a node, use the [Download] trait. /// To upload a node, use the [Upload] trait. /// To manage rooms, use the [Rooms] trait. @@ -42,20 +39,20 @@ pub trait Nodes { /// # .await /// # .unwrap(); /// let nodes = dracoon.get_nodes(None, None, None).await.unwrap(); - /// + /// /// // get all nodes for a parent /// let nodes = dracoon.get_nodes(Some(123), None, None).await.unwrap(); - /// + /// /// // get all nodes visible as room manager / admin /// let nodes = dracoon.get_nodes(None, Some(true), None).await.unwrap(); - /// - /// // use filtering and sorting + /// + /// // use filtering and sorting /// let params = ListAllParams::builder() /// .with_filter(NodesFilter::is_file()) /// .with_filter(NodesFilter::name_contains("foo")) /// .with_sort(NodesSortBy::name(SortOrder::Desc)) /// .build(); - /// + /// /// let nodes = dracoon.get_nodes(None, None, Some(params)).await.unwrap(); /// # } /// ``` @@ -103,13 +100,13 @@ pub trait Nodes { /// # .unwrap(); /// // search for nodes ("*" is wildcard) /// let nodes = dracoon.search_nodes("foo", None, None, None).await.unwrap(); - /// + /// /// // search for nodes in a parent /// let nodes = dracoon.search_nodes("foo", Some(123), None, None).await.unwrap(); - /// + /// /// // search for nodes in a parent with a depth level (-1 is full tree) /// let nodes = dracoon.search_nodes("foo", Some(123), Some(1), None).await.unwrap(); - /// + /// /// // use filtering and sorting /// let params = ListAllParams::builder() /// .with_filter(NodesSearchFilter::is_file()) @@ -230,6 +227,7 @@ pub trait Nodes { target_parent_id: u64, ) -> Result; } + #[async_trait] pub trait Folders { /// Creates a folder in the provided parent room. @@ -282,7 +280,7 @@ pub trait Folders { ) -> Result; } /// This trait provides methods to manage rooms. -/// +/// /// - Create a room /// - Update a room /// - Configure a room @@ -437,7 +435,7 @@ pub trait Rooms { /// # .connect(OAuth2Flow::PasswordFlow("username".into(), "password".into())) /// # .await /// # .unwrap(); - /// + /// /// // add a a list of updates /// let group_updates = vec![RoomGroupsAddBatchRequestItem::new(123, NodePermissions::new_with_read_permissions(), None)]; /// dracoon.update_room_groups(123, group_updates.into()).await.unwrap(); @@ -464,7 +462,7 @@ pub trait Rooms { /// # .connect(OAuth2Flow::PasswordFlow("username".into(), "password".into())) /// # .await /// # .unwrap(); - /// // You can use a vec + /// // You can use a vec /// let group_ids = vec![1, 2, 3]; /// dracoon.delete_room_groups(123, group_ids.into()).await.unwrap(); /// # } @@ -515,7 +513,7 @@ pub trait Rooms { /// # .connect(OAuth2Flow::PasswordFlow("username".into(), "password".into())) /// # .await /// # .unwrap(); - /// + /// /// // add a a list of updates /// let user_updates = vec![RoomUsersAddBatchRequestItem::new(123, NodePermissions::new_with_read_permissions())]; /// dracoon.update_room_users(123, user_updates.into()).await.unwrap(); @@ -541,7 +539,7 @@ pub trait Rooms { /// # .connect(OAuth2Flow::PasswordFlow("username".into(), "password".into())) /// # .await /// # .unwrap(); - /// // You can use a vec + /// // You can use a vec /// let user_ids = vec![1, 2, 3]; /// dracoon.delete_room_users(123, user_ids.into()).await.unwrap(); /// # } @@ -576,7 +574,7 @@ pub trait Download { /// .connect(OAuth2Flow::password_flow("username", "password")) /// .await /// .unwrap(); - /// + /// /// let node_id = 123u64; /// /// let node = client.get_node(node_id).await.unwrap(); @@ -626,7 +624,7 @@ pub trait Upload { /// .connect(OAuth2Flow::password_flow("username", "password")) /// .await /// .unwrap(); - /// + /// /// let file = tokio::fs::File::open("test.txt").await.unwrap(); /// let file_meta = FileMeta::builder() /// .with_name("test.txt".into()) @@ -636,19 +634,19 @@ pub trait Upload { /// /// /// let parent_node_id = 123u64; - /// + /// /// let parent_node = client.get_node(parent_node_id).await.unwrap(); - /// + /// /// let reader = tokio::io::BufReader::new(file); - /// + /// /// let options = UploadOptions::builder() /// .with_resolution_strategy(ResolutionStrategy::AutoRename) /// .build(); - /// + /// /// let chunk_size = 1024 * 1024 * 10; // 10 MB - DEFAULT is 32 MB - /// + /// /// client.upload(file_meta, &parent_node, options, reader, None, Some(chunk_size)).await.unwrap(); - /// + /// /// // or with progress callback (boxed closure) /// let file = tokio::fs::File::open("test.txt").await.unwrap(); /// let file_meta = FileMeta::builder() diff --git a/src/nodes/models/mod.rs b/src/nodes/models/mod.rs index b5db0c9..e881300 100644 --- a/src/nodes/models/mod.rs +++ b/src/nodes/models/mod.rs @@ -1087,3 +1087,25 @@ impl UserFileKeySetRequest { } } } + + +#[derive(Debug, Clone)] +pub enum UseKey { + RoomRescueKey, + SystemRescueKey, + PreviousUserKey, + PreviousRoomRescueKey, + PreviousSystemRescueKey, +} + +impl From for String { + fn from(use_key: UseKey) -> Self { + match use_key { + UseKey::RoomRescueKey => "room_rescue_key".to_string(), + UseKey::SystemRescueKey => "system_rescue_key".to_string(), + UseKey::PreviousUserKey => "previous_user_key".to_string(), + UseKey::PreviousRoomRescueKey => "previous_room_rescue_key".to_string(), + UseKey::PreviousSystemRescueKey => "previous_system_rescue_key".to_string(), + } + } +} \ No newline at end of file diff --git a/src/nodes/nodes.rs b/src/nodes/nodes.rs index d4c6334..3b636f9 100644 --- a/src/nodes/nodes.rs +++ b/src/nodes/nodes.rs @@ -42,7 +42,7 @@ impl Nodes for Dracoon { .extend_pairs(room_manager.map(|v| ("room_manager", v.to_string()))) .extend_pairs(parent_id.map(|v| ("parent_id", v.to_string()))) .finish(); - + let response = self .client .http @@ -59,17 +59,11 @@ impl Nodes for Dracoon { // TODO: refactor and make use of search_nodes let url_part = format!("/{DRACOON_API_PREFIX}/{NODES_BASE}/{NODES_SEARCH}"); - debug!("Looking up node - path: {}", path); - let (parent_path, name, depth) = parse_node_path(path).map_err(|_| { error!("Failed to parse path: {}", path); DracoonClientError::InvalidPath(path.to_string()) })?; - debug!("Looking up node - parent_path: {}", parent_path); - debug!("Parsed name: {}", name); - debug!("Calculated depth: {}", depth); - let mut api_url = self.build_api_url(&url_part); api_url @@ -252,16 +246,19 @@ pub fn parse_node_path(path: &str) -> Result { if path == "/" { return Ok((String::from("/"), String::new(), 0)); } - + let path_parts: Vec<&str> = path.trim_end_matches('/').split('/').collect(); - let name = String::from(*path_parts.last().ok_or(DracoonClientError::InvalidPath(path.to_string()))?); + let name = String::from( + *path_parts + .last() + .ok_or(DracoonClientError::InvalidPath(path.to_string()))?, + ); let parent_path = format!("{}/", path_parts[..path_parts.len() - 1].join("/")); let depth = path_parts.len().saturating_sub(2) as u64; - + Ok((parent_path, name, depth)) } - #[cfg(test)] mod tests { use super::*; @@ -319,4 +316,4 @@ mod tests { assert_eq!("", name); assert_eq!(0, depth); } -} \ No newline at end of file +} diff --git a/src/settings/keypair.rs b/src/settings/keypair.rs new file mode 100644 index 0000000..e499333 --- /dev/null +++ b/src/settings/keypair.rs @@ -0,0 +1,478 @@ +use async_trait::async_trait; +use dco3_crypto::{ + DracoonCrypto, DracoonRSACrypto, PlainUserKeyPairContainer, UserKeyPairContainer, +}; +use reqwest::header; + +use crate::{ + auth::Connected, + constants::{ + DRACOON_API_PREFIX, FILES_BASE, FILES_KEYS, MISSING_FILE_KEYS, NODES_BASE, SETTINGS_BASE, + SETTINGS_KEYPAIR, + }, + nodes::{MissingKeysResponse, UserFileKeySetBatchRequest, UserFileKeySetRequest}, + utils::FromResponse, + Dracoon, DracoonClientError, ListAllParams, +}; + +use super::RescueKeyPair; + +const MISSING_KEYS_LIMIT: u64 = 100; + +#[async_trait] +impl RescueKeyPair for Dracoon { + async fn distribute_missing_keys( + &self, + rescue_key_secret: &str, + room_id: Option, + file_id: Option, + user_id: Option, + ) -> Result { + let keypair = self.get_system_rescue_keypair(rescue_key_secret).await?; + + let missing_keys = self + .get_missing_file_keys(room_id, file_id, user_id, None) + .await?; + + let remaining_keys = if missing_keys.range.is_none() { + 0 + } else { + missing_keys.range.as_ref().unwrap().total + }; + + let key_reqs = self.prepare_key_requests(missing_keys, &keypair)?; + + if !key_reqs.is_empty() { + self.set_file_keys(key_reqs.into()).await?; + } + + + Ok(remaining_keys) + } +} + +#[async_trait] +trait RescueKeypairInternal { + async fn get_missing_file_keys( + &self, + room_id: Option, + file_id: Option, + user_id: Option, + params: Option, + ) -> Result; + + async fn set_file_keys( + &self, + req: UserFileKeySetBatchRequest, + ) -> Result<(), DracoonClientError>; + + async fn get_system_rescue_keypair( + &self, + secret: &str, + ) -> Result; + + fn prepare_key_requests( + &self, + missing_keys: MissingKeysResponse, + keypair: &PlainUserKeyPairContainer, + ) -> Result, DracoonClientError> { + let reqs = missing_keys + .items + .into_iter() + .flat_map::, _>(|item| { + let file_id = item.file_id; + let user_id = item.user_id; + let public_key = missing_keys + .users + .iter() + .find(|u| u.id == user_id) + .expect("User not found") // this is safe because the user id is in the response + .public_key_container + .clone(); + let file_key = missing_keys + .files + .iter() + .find(|f| f.id == file_id) + .expect("File not found") // this is safe because the file id is in the response + .file_key_container + .clone(); + + let plain_file_key = DracoonCrypto::decrypt_file_key(file_key, keypair)?; + let file_key = DracoonCrypto::encrypt_file_key(plain_file_key, public_key)?; + let set_key_req = UserFileKeySetRequest::new(file_id, user_id, file_key); + Ok(set_key_req) + }) + .collect::>(); + + Ok(reqs) + } +} + +#[async_trait] +impl RescueKeypairInternal for Dracoon { + async fn get_missing_file_keys( + &self, + room_id: Option, + file_id: Option, + user_id: Option, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let url_part = format!("{DRACOON_API_PREFIX}/{NODES_BASE}/{MISSING_FILE_KEYS}"); + + let limit = params.limit.unwrap_or(100); + + let mut api_url = self.build_api_url(&url_part); + + let sorts = params.sort_to_string(); + + api_url + .query_pairs_mut() + .extend_pairs(Some(("limit", limit.to_string()))) + .extend_pairs(params.offset.map(|v| ("offset", v.to_string()))) + .extend_pairs(params.sort.map(|_| ("sort", sorts))) + .extend_pairs(user_id.map(|id| ("user_id", id.to_string()))) + .extend_pairs(room_id.map(|id| ("room_id", id.to_string()))) + .extend_pairs(file_id.map(|id| ("file_id", id.to_string()))) + .finish(); + + println!("URL: {}", api_url); + + let response = self + .client + .http + .get(api_url) + .header(header::AUTHORIZATION, self.get_auth_header().await?) + .send() + .await?; + + MissingKeysResponse::from_response(response).await + } + + async fn set_file_keys( + &self, + req: UserFileKeySetBatchRequest, + ) -> Result<(), DracoonClientError> { + let url_part = format!("{DRACOON_API_PREFIX}/{NODES_BASE}/{FILES_BASE}/{FILES_KEYS}"); + + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .post(api_url) + .header(header::AUTHORIZATION, self.get_auth_header().await?) + .json(&req) + .send() + .await?; + + if response.status().is_server_error() || response.status().is_client_error() { + return Err(DracoonClientError::from_response(response) + .await + .expect("Could not parse error response")); + } + + Ok(()) + } + + async fn get_system_rescue_keypair( + &self, + secret: &str, + ) -> Result { + let url_part = format!("{DRACOON_API_PREFIX}/{SETTINGS_BASE}/{SETTINGS_KEYPAIR}",); + + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .get(api_url) + .header(header::AUTHORIZATION, self.get_auth_header().await?) + .send() + .await?; + + let keypair = UserKeyPairContainer::from_response(response).await?; + + let keypair = DracoonCrypto::decrypt_private_key(secret, keypair)?; + + Ok(keypair) + } +} + +#[cfg(test)] +mod tests { + use dco3_crypto::{ + DracoonCrypto, DracoonCryptoError, DracoonRSACrypto, FileKeyVersion, UserKeyPairContainer, + UserKeyPairVersion, + }; + + use crate::{ + nodes::MissingKeysResponse, + settings::{keypair::RescueKeypairInternal, RescueKeyPair}, + tests::dracoon::get_connected_client, + DracoonClientError, + }; + + #[tokio::test] + async fn test_get_missing_file_keys() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + + let missing_keys_mock = mock_server + .mock("GET", "/api/v4/nodes/missingFileKeys?limit=100&offset=0") + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let missing_keys = client + .get_missing_file_keys(None, None, None, None) + .await + .unwrap(); + + missing_keys_mock.assert(); + + assert_eq!(missing_keys.range.unwrap().total, 1); + assert_eq!(missing_keys.items.len(), 1); + assert_eq!(missing_keys.users.len(), 1); + assert_eq!(missing_keys.files.len(), 1); + + let item = missing_keys.items.first().unwrap(); + let user = missing_keys.users.first().unwrap(); + let file = missing_keys.files.first().unwrap(); + + assert_eq!(item.file_id, 3); + assert_eq!(item.user_id, 2); + assert_eq!(user.id, 2); + assert_eq!(file.id, 3); + assert_eq!( + file.file_key_container.version, + FileKeyVersion::RSA4096_AES256GCM + ); + assert_eq!(file.file_key_container.key, "string"); + assert_eq!(file.file_key_container.iv, "string"); + assert_eq!(file.file_key_container.tag.as_ref().unwrap(), "string"); + assert_eq!( + user.public_key_container.version, + UserKeyPairVersion::RSA4096 + ); + + assert_eq!(item.user_id, user.id); + assert_eq!(item.file_id, file.id); + } + + #[ignore = "todo - not implemented yet"] + #[tokio::test] + async fn test_set_file_keys() { + todo!() + } + + #[tokio::test] + async fn test_get_system_rescue_keypair() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/keypair_ok.json"); + + let keypair_mock = mock_server + .mock("GET", "/api/v4/settings/keypair") + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let keypair = client + .get_system_rescue_keypair("TopSecret1234!") + .await + .unwrap(); + + keypair_mock.assert(); + } + + #[ignore = "needs updating mock file key response"] + #[tokio::test] + async fn test_prepare_key_requests() { + let (client, _) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + let missing_keypairs: MissingKeysResponse = serde_json::from_str(response).unwrap(); + let keypair_response = include_str!("../tests/responses/keypair_ok.json"); + let keypair: UserKeyPairContainer = serde_json::from_str(keypair_response).unwrap(); + let plain_keypair = DracoonCrypto::decrypt_private_key("TopSecret1234!", keypair).unwrap(); + + let key_reqs = client + .prepare_key_requests(missing_keypairs, &plain_keypair) + .unwrap(); + + // TODO: update mock file key response - currently no request is built because file key is not decrypted + assert_eq!(key_reqs.len(), 1); + } + + #[tokio::test] + async fn test_distribute_missing_keys() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + let keypair_response = include_str!("../tests/responses/keypair_ok.json"); + + let missing_keys_mock = mock_server + .mock("GET", "/api/v4/nodes/missingFileKeys?limit=100&offset=0") + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let keypair_mock = mock_server + .mock("GET", "/api/v4/settings/keypair") + .with_body(keypair_response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let res = client + .distribute_missing_keys("TopSecret1234!", None, None, None) + .await; + + assert!(res.is_ok()); + + missing_keys_mock.assert(); + keypair_mock.assert(); + } + + #[tokio::test] + async fn test_distribute_missing_keys_wrong_secret() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + let keypair_response = include_str!("../tests/responses/keypair_ok.json"); + + let missing_keys_mock = mock_server + .mock("GET", "/api/v4/nodes/missingFileKeys?limit=100&offset=0") + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let keypair_mock = mock_server + .mock("GET", "/api/v4/settings/keypair") + .with_body(keypair_response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let res = client + .distribute_missing_keys("wrongsecret", None, None, None) + .await; + + assert!(res.is_err()); + + keypair_mock.assert(); + + assert_eq!( + res.unwrap_err(), + DracoonClientError::CryptoError(DracoonCryptoError::RsaOperationFailed) + ); + } + + #[tokio::test] + async fn test_distribute_missing_keys_with_room_id() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + let keypair_response = include_str!("../tests/responses/keypair_ok.json"); + + let missing_keys_mock = mock_server + .mock( + "GET", + "/api/v4/nodes/missingFileKeys?limit=100&offset=0&room_id=1", + ) + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let keypair_mock = mock_server + .mock("GET", "/api/v4/settings/keypair") + .with_body(keypair_response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let res = client + .distribute_missing_keys("TopSecret1234!", Some(1), None, None) + .await; + + assert!(res.is_ok()); + + missing_keys_mock.assert(); + keypair_mock.assert(); + } + + #[tokio::test] + async fn test_distribute_missing_keys_with_file_id() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + let keypair_response = include_str!("../tests/responses/keypair_ok.json"); + + let missing_keys_mock = mock_server + .mock( + "GET", + "/api/v4/nodes/missingFileKeys?limit=100&offset=0&file_id=3", + ) + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let keypair_mock = mock_server + .mock("GET", "/api/v4/settings/keypair") + .with_body(keypair_response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let res = client + .distribute_missing_keys("TopSecret1234!", None, Some(3), None) + .await; + + assert!(res.is_ok()); + + missing_keys_mock.assert(); + keypair_mock.assert(); + } + + #[tokio::test] + async fn test_distribute_missing_keys_with_user_id() { + let (client, mut mock_server) = get_connected_client().await; + + let response = include_str!("../tests/responses/nodes/missing_file_keys_ok.json"); + let keypair_response = include_str!("../tests/responses/keypair_ok.json"); + + let missing_keys_mock = mock_server + .mock( + "GET", + "/api/v4/nodes/missingFileKeys?limit=100&offset=0&user_id=2", + ) + .with_body(response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let keypair_mock = mock_server + .mock("GET", "/api/v4/settings/keypair") + .with_body(keypair_response) + .with_header("content-type", "application/json") + .with_status(200) + .create(); + + let res = client + .distribute_missing_keys("TopSecret1234!", None, None, Some(2)) + .await; + + assert!(res.is_ok()); + + missing_keys_mock.assert(); + keypair_mock.assert(); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 0000000..a0e5518 --- /dev/null +++ b/src/settings/mod.rs @@ -0,0 +1,53 @@ +use async_trait::async_trait; + +use crate::DracoonClientError; + +mod keypair; + +#[async_trait] +/// This trait currently implements distributing missing keys uing the system rescue key. +pub trait RescueKeyPair { + /// Distributes missing file keys using the rescue key. + /// Returns the total amount missing keys. + /// If the total amount is larger than 100, more keys need distribution + /// and the method should be called again. + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, RescueKeyPair}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_client_id("client_id") + /// # .with_client_secret("client_secret") + /// # .build() + /// # .unwrap() + /// # .connect(OAuth2Flow::PasswordFlow("username".into(), "password".into())) + /// # .await + /// # .unwrap(); + /// let mut missing_keys = dracoon.distribute_missing_keys("rescue_key_secret", None, None, None).await.unwrap(); + /// + /// while missing_keys > 100 { + /// // loop until no more keys need distribution + /// missing_keys = dracoon.distribute_missing_keys("rescue_key_secret", None, None, None).await.unwrap(); + /// } + /// + /// // distribute missing keys for a specific room + /// let missing_room_keys = dracoon.distribute_missing_keys("rescue_key_secret", Some(123), None, None).await.unwrap(); + /// + /// // distribute missing keys for a specific file + /// let missing_file_keys = dracoon.distribute_missing_keys("rescue_key_secret", None, Some(123), None).await.unwrap(); + /// + /// // distribute missing keys for a specific user + /// let missing_user_keys = dracoon.distribute_missing_keys("rescue_key_secret", None, None, Some(123)).await.unwrap(); + /// + /// # } + /// + async fn distribute_missing_keys( + &self, + rescue_key_secret: &str, + room_id: Option, + file_id: Option, + user_id: Option + ) -> Result; +} + diff --git a/src/tests/user.rs b/src/tests/user.rs index 7aa7c7d..2bc111e 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -4,7 +4,7 @@ mod tests { use crate::{ tests::dracoon::{get_connected_client, assert_user_account}, user::UpdateUserAccountRequest, User, - UserAccountKeypairs, + UserAccountKeyPairs, }; #[tokio::test] diff --git a/src/user/keypairs.rs b/src/user/keypairs.rs index 56e6f17..4ad6216 100644 --- a/src/user/keypairs.rs +++ b/src/user/keypairs.rs @@ -1,4 +1,4 @@ -use super::UserAccountKeypairs; +use super::UserAccountKeyPairs; use crate::{ auth::{errors::DracoonClientError, Connected}, constants::{DRACOON_API_PREFIX, USER_ACCOUNT, USER_ACCOUNT_KEYPAIR, USER_BASE}, @@ -12,7 +12,7 @@ use dco3_crypto::{ use reqwest::header; #[async_trait] -impl UserAccountKeypairs for Dracoon { +impl UserAccountKeyPairs for Dracoon { async fn get_user_keypair( &self, secret: &str, diff --git a/src/user/mod.rs b/src/user/mod.rs index 93322d6..37e3c15 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -1,16 +1,15 @@ -//! This module implements a subset of the DRACOON user API. +//! This module implements a subset of the DRACOON user API. //! Documentation can be found here: use async_trait::async_trait; -use dco3_crypto::{PlainUserKeyPairContainer}; +use dco3_crypto::PlainUserKeyPairContainer; pub use self::models::*; use super::auth::errors::DracoonClientError; -pub mod models; pub mod account; pub mod keypairs; - +pub mod models; #[async_trait] pub trait User { @@ -32,7 +31,7 @@ pub trait User { /// # } /// ``` async fn get_user_account(&self) -> Result; - /// Update the user account information. + /// Update the user account information. /// ```no_run /// # use dco3::{Dracoon, auth::OAuth2Flow, User, user::{UpdateUserAccountRequest}}; /// # #[tokio::main] @@ -46,25 +45,28 @@ pub trait User { /// # .connect(OAuth2Flow::PasswordFlow("username".into(), "password".into())) /// # .await /// # .unwrap(); - /// + /// /// let update = UpdateUserAccountRequest::builder() /// .with_first_name("Jane") /// .with_last_name("Doe") /// .with_email("jane.doe@localhost") /// .build(); - /// + /// /// let account = dracoon.update_user_account(update).await.unwrap(); /// # } /// ``` - async fn update_user_account(&self, update: UpdateUserAccountRequest) -> Result; + async fn update_user_account( + &self, + update: UpdateUserAccountRequest, + ) -> Result; } #[async_trait] #[allow(clippy::module_name_repetitions)] -pub trait UserAccountKeypairs { +pub trait UserAccountKeyPairs { /// Get the plain user keypair container. /// ```no_run - /// # use dco3::{Dracoon, auth::OAuth2Flow, UserAccountKeypairs}; + /// # use dco3::{Dracoon, auth::OAuth2Flow, UserAccountKeyPairs}; /// # #[tokio::main] /// # async fn main() { /// # let dracoon = Dracoon::builder() @@ -81,10 +83,13 @@ pub trait UserAccountKeypairs { /// // is handled by the dracoon client for up- and downloads. /// # } /// ``` - async fn get_user_keypair(&self, secret: &str) -> Result; + async fn get_user_keypair( + &self, + secret: &str, + ) -> Result; /// Set the user keypair container. /// ```no_run - /// # use dco3::{Dracoon, auth::OAuth2Flow, UserAccountKeypairs}; + /// # use dco3::{Dracoon, auth::OAuth2Flow, UserAccountKeyPairs}; /// # #[tokio::main] /// # async fn main() { /// # let dracoon = Dracoon::builder() @@ -103,7 +108,7 @@ pub trait UserAccountKeypairs { async fn set_user_keypair(&self, secret: &str) -> Result<(), DracoonClientError>; /// Delete the user keypair container. /// ```no_run - /// # use dco3::{Dracoon, auth::OAuth2Flow, UserAccountKeypairs}; + /// # use dco3::{Dracoon, auth::OAuth2Flow, UserAccountKeyPairs}; /// # #[tokio::main] /// # async fn main() { /// # let dracoon = Dracoon::builder() @@ -120,4 +125,4 @@ pub trait UserAccountKeypairs { /// # } /// ``` async fn delete_user_keypair(&self) -> Result<(), DracoonClientError>; -} \ No newline at end of file +} diff --git a/src/users/models.rs b/src/users/models.rs index d013a35..538b9de 100644 --- a/src/users/models.rs +++ b/src/users/models.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{ auth::DracoonErrorResponse, models::{ObjectExpiration, RangedItems}, - user::{RoleList}, + user::RoleList, utils::{parse_body, FromResponse}, DracoonClientError, FilterOperator, FilterQuery, SortOrder, SortQuery, }; @@ -123,7 +123,10 @@ impl From for MfaConfig { } impl CreateUserRequest { - pub fn builder(first_name: impl Into, last_name: impl Into) -> CreateUserRequestBuilder { + pub fn builder( + first_name: impl Into, + last_name: impl Into, + ) -> CreateUserRequestBuilder { CreateUserRequestBuilder::new(first_name, last_name) } } @@ -398,7 +401,6 @@ impl UserAuthData { } pub fn new_basic(password: Option) -> Self { - let must_change_password = password.is_some(); Self { @@ -414,7 +416,11 @@ impl UserAuthData { pub fn new_oidc(login: impl Into, oid_config_id: u64) -> Self { let login: String = login.into(); Self { - method: AuthMethod::OpenIdConnect{ login: login.clone(), oid_config_id }.into(), + method: AuthMethod::OpenIdConnect { + login: login.clone(), + oid_config_id, + } + .into(), login: Some(login), ad_config_id: None, oid_config_id: Some(oid_config_id), @@ -426,7 +432,11 @@ impl UserAuthData { pub fn new_ad(login: impl Into, ad_config_id: u64) -> Self { let login: String = login.into(); Self { - method: AuthMethod::ActiveDirectory{ login: login.clone(), ad_config_id }.into(), + method: AuthMethod::ActiveDirectory { + login: login.clone(), + ad_config_id, + } + .into(), login: Some(login), ad_config_id: Some(ad_config_id), oid_config_id: None, @@ -436,8 +446,6 @@ impl UserAuthData { } } - - #[derive(Debug, Clone)] pub enum AuthMethod { Basic, @@ -566,7 +574,6 @@ impl UserAuthDataBuilder { } } - #[derive(Debug)] pub enum UsersFilter { Email(FilterOperator, String), @@ -587,44 +594,43 @@ impl FilterQuery for UsersFilter { Self::Email(op, value) => { let op: String = op.into(); format!("email:{}:{}", op, value) - }, + } Self::UserName(op, value) => { let op: String = op.into(); format!("userName:{}:{}", op, value) - }, + } Self::FirstName(op, value) => { let op: String = op.into(); format!("firstName:{}:{}", op, value) - }, + } Self::LastName(op, value) => { let op: String = op.into(); format!("lastName:{}:{}", op, value) - }, + } Self::IsLocked(op, value) => { let op: String = op.into(); format!("isLocked:{}:{}", op, value) - }, + } Self::EffectiveRoles(op, value) => { let op: String = op.into(); format!("effectiveRoles:{}:{}", op, value) - }, + } Self::CreatedAt(op, value) => { let op: String = op.into(); format!("createdAt:{}:{}", op, value) - }, + } Self::Phone(op, value) => { let op: String = op.into(); format!("phone:{}:{}", op, value) - }, + } Self::IsEncryptionEnabled(op, value) => { let op: String = op.into(); format!("isEncryptionEnabled:{}:{}", op, value) - }, + } Self::HasRole(op, value) => { let op: String = op.into(); format!("hasRole:{}:{}", op, value) - }, - + } } } } @@ -700,31 +706,31 @@ impl SortQuery for UsersSortBy { Self::UserName(order) => { let order: String = order.into(); format!("userName:{}", order) - }, + } Self::Email(order) => { let order: String = order.into(); format!("email:{}", order) - }, + } Self::FirstName(order) => { let order: String = order.into(); format!("firstName:{}", order) - }, + } Self::LastName(order) => { let order: String = order.into(); format!("lastName:{}", order) - }, + } Self::IsLocked(order) => { let order: String = order.into(); format!("isLocked:{}", order) - }, + } Self::ExpireAt(order) => { let order: String = order.into(); format!("expireAt:{}", order) - }, + } Self::CreatedAt(order) => { let order: String = order.into(); format!("createdAt:{}", order) - }, + } } } } @@ -769,4 +775,4 @@ impl From for Box { fn from(f: UsersFilter) -> Self { Box::new(f) } -} \ No newline at end of file +}