From 40d05fcec82949a91fd430f131d8ff28d56e384d Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 3 Dec 2025 18:00:43 -0800 Subject: [PATCH] feat: add cloud credential entity and migration --- apps/tauri/src/App.tsx | 8 +- core/:memory: | Bin 1589248 -> 1589248 bytes core/src/crypto/key_manager.rs | 2 +- core/src/filetype/definitions/code.toml | 4 +- .../src/infra/db/entities/cloud_credential.rs | 26 ++ core/src/infra/db/entities/mod.rs | 3 + ...4_000001_create_cloud_credentials_table.rs | 85 ++++ core/src/infra/db/migration/mod.rs | 2 + core/src/lib.rs | 16 +- core/src/ops/indexing/entry.rs | 61 +-- core/src/ops/indexing/job.rs | 8 + core/src/ops/indexing/phases/discovery.rs | 37 +- core/src/ops/network/revoke/action.rs | 16 +- .../src/service/network/device/persistence.rs | 318 ++++---------- core/src/service/network/device/registry.rs | 17 +- docs/core/key-manager.mdx | 394 ++++++++++++++++++ docs/core/networking.mdx | 24 +- .../Explorer/components/AddStorageModal.tsx | 180 ++++---- packages/ui/src/Dialog.tsx | 47 ++- 19 files changed, 811 insertions(+), 437 deletions(-) create mode 100644 core/src/infra/db/entities/cloud_credential.rs create mode 100644 core/src/infra/db/migration/m20251204_000001_create_cloud_credentials_table.rs create mode 100644 docs/core/key-manager.mdx diff --git a/apps/tauri/src/App.tsx b/apps/tauri/src/App.tsx index 478135ba9..ced0dfdc4 100644 --- a/apps/tauri/src/App.tsx +++ b/apps/tauri/src/App.tsx @@ -29,11 +29,9 @@ function App() { // React Scan disabled - too heavy for development // Uncomment if you need to debug render performance: // if (import.meta.env.DEV) { - // setTimeout(() => { - // import("react-scan").then(({ scan }) => { - // scan({ enabled: false, log: false }); - // }); - // }, 2000); + // import("react-scan").then(({ scan }) => { + // scan({ enabled: true, log: false }); + // }); // } // Initialize Tauri native context menu handler diff --git a/core/:memory: b/core/:memory: index 7b7ec30e56dcb9640cf79e80ef54150bdf6ce23b..273d1bf474b117ec45c9072f9fd7b55bd1ef67b1 100644 GIT binary patch delta 127 zcmXBKJr2Qe9LHhRR{7ET)N5m~xPZwKh_Oeg)M65o@gKuCPGOW7O*U~BaRjS`#4|t6 z70wm5TbHYmU1E!_Hukcx^5?(aoBaNn&$h?Ya`D{X@=5SW4hTTKNTI(6+O`x12H_4O~2TMAhZAg delta 127 zcmZo@NNi|GZ`r?eeQdHx95D$McV_FSq|_u z3A70?wh1t`2{5+_u(S!Vwh6Gc39z>baI^_, + + pub service_type: String, + + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/core/src/infra/db/entities/mod.rs b/core/src/infra/db/entities/mod.rs index bd1c0e358..a859ebae9 100644 --- a/core/src/infra/db/entities/mod.rs +++ b/core/src/infra/db/entities/mod.rs @@ -3,6 +3,7 @@ //! These map our domain models to database tables. pub mod audio_media_data; +pub mod cloud_credential; pub mod content_identity; pub mod content_kind; pub mod device; @@ -39,6 +40,7 @@ pub mod volume; // Re-export all entities pub use audio_media_data::Entity as AudioMediaData; pub use audit_log::Entity as AuditLog; +pub use cloud_credential::Entity as CloudCredential; pub use collection::Entity as Collection; pub use collection_entry::Entity as CollectionEntry; pub use content_identity::Entity as ContentIdentity; @@ -71,6 +73,7 @@ pub use user_metadata_tag::Entity as UserMetadataTag; // Re-export active models for easy access pub use audio_media_data::ActiveModel as AudioMediaDataActive; pub use audit_log::ActiveModel as AuditLogActive; +pub use cloud_credential::ActiveModel as CloudCredentialActive; pub use collection::ActiveModel as CollectionActive; pub use collection_entry::ActiveModel as CollectionEntryActive; pub use content_identity::ActiveModel as ContentIdentityActive; diff --git a/core/src/infra/db/migration/m20251204_000001_create_cloud_credentials_table.rs b/core/src/infra/db/migration/m20251204_000001_create_cloud_credentials_table.rs new file mode 100644 index 000000000..d9b5b4852 --- /dev/null +++ b/core/src/infra/db/migration/m20251204_000001_create_cloud_credentials_table.rs @@ -0,0 +1,85 @@ +//! Create cloud_credentials table for storing encrypted cloud service credentials + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CloudCredentials::Table) + .if_not_exists() + .col( + ColumnDef::new(CloudCredentials::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(CloudCredentials::VolumeFingerprint) + .string() + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(CloudCredentials::EncryptedCredential) + .binary() + .not_null(), + ) + .col( + ColumnDef::new(CloudCredentials::ServiceType) + .string() + .not_null(), + ) + .col( + ColumnDef::new(CloudCredentials::CreatedAt) + .timestamp() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(CloudCredentials::UpdatedAt) + .timestamp() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + // Create index on volume_fingerprint for fast lookups + manager + .create_index( + Index::create() + .name("idx_cloud_credentials_volume_fingerprint") + .table(CloudCredentials::Table) + .col(CloudCredentials::VolumeFingerprint) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CloudCredentials::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum CloudCredentials { + Table, + Id, + VolumeFingerprint, + EncryptedCredential, + ServiceType, + CreatedAt, + UpdatedAt, +} diff --git a/core/src/infra/db/migration/mod.rs b/core/src/infra/db/migration/mod.rs index 591cd4bb1..595bbbef5 100644 --- a/core/src/infra/db/migration/mod.rs +++ b/core/src/infra/db/migration/mod.rs @@ -28,6 +28,7 @@ mod m20251117_000002_add_unique_constraint_to_entries; mod m20251117_000003_add_unique_bytes_to_volumes; mod m20251129_000001_add_entry_id_to_space_items; mod m20251202_000001_add_cloud_config_to_volumes; +mod m20251204_000001_create_cloud_credentials_table; pub struct Migrator; @@ -61,6 +62,7 @@ impl MigratorTrait for Migrator { Box::new(m20251117_000003_add_unique_bytes_to_volumes::Migration), Box::new(m20251129_000001_add_entry_id_to_space_items::Migration), Box::new(m20251202_000001_add_cloud_config_to_volumes::Migration), + Box::new(m20251204_000001_create_cloud_credentials_table::Migration), ] } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 3b426eb01..c36b4faa6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -102,8 +102,15 @@ impl Core { let config = Arc::new(RwLock::new(config)); + // Initialize unified key manager with file fallback + let device_key_fallback = data_dir.join("device_key"); + let key_manager = Arc::new(crate::crypto::key_manager::KeyManager::new_with_fallback( + data_dir.clone(), + Some(device_key_fallback), + )?); + // Initialize device manager - let device = Arc::new(DeviceManager::init(&data_dir, system_device_name)?); + let device = Arc::new(DeviceManager::init(&data_dir, key_manager.clone(), system_device_name)?); // Set a global device ID and slug for convenience crate::device::set_current_device_id(device.device_id()?); @@ -133,13 +140,6 @@ impl Core { } drop(config_read); - // Initialize unified key manager with file fallback - let device_key_fallback = data_dir.join("device_key"); - let key_manager = Arc::new(crate::crypto::key_manager::KeyManager::new_with_fallback( - data_dir.clone(), - Some(device_key_fallback), - )?); - // Create the context that will be shared with services let mut context_inner = CoreContext::new( events.clone(), diff --git a/core/src/ops/indexing/entry.rs b/core/src/ops/indexing/entry.rs index e1713bdb8..1b908c2f4 100644 --- a/core/src/ops/indexing/entry.rs +++ b/core/src/ops/indexing/entry.rs @@ -15,6 +15,17 @@ use sea_orm::{ use std::path::{Path, PathBuf}; use uuid::Uuid; +/// Normalize cloud directory path for consistent lookups +/// Cloud paths stored with trailing slashes don't match PathBuf::parent() results +fn normalize_cloud_dir_path(path: &Path) -> PathBuf { + let path_str = path.to_string_lossy(); + if path_str.contains("://") && path_str.ends_with('/') { + PathBuf::from(path_str.trim_end_matches('/')) + } else { + path.to_path_buf() + } +} + /// Metadata about a file system entry #[derive(Debug, Clone)] pub struct EntryMetadata { @@ -218,37 +229,34 @@ impl EntryProcessor { // Find parent entry ID let parent_id = if let Some(parent_path) = entry.path.parent() { + ctx.log(format!( + "Looking up parent for {}: parent_path = {}", + entry.path.display(), + parent_path.display() + )); + // First check the cache if let Some(id) = state.entry_id_cache.get(parent_path).copied() { + ctx.log(format!("Found parent in cache: id = {}", id)); Some(id) } else { // If not in cache, try to find it in the database - // This handles cases where parent was created in a previous run + // For cloud paths, try both with and without trailing slash let parent_path_str = parent_path.to_string_lossy().to_string(); + let is_cloud = parent_path_str.contains("://"); - // For cloud paths, directory paths may have trailing slashes - // Try both with and without to handle path normalization differences - let parent_with_slash = - if !parent_path_str.ends_with('/') && parent_path_str.contains("://") { - Some(format!("{}/", parent_path_str)) - } else { - None - }; - - let mut query = entities::directory_paths::Entity::find(); - if let Some(alt_path) = &parent_with_slash { - // Try both variants for cloud paths - query = query.filter( - entities::directory_paths::Column::Path.is_in([&parent_path_str, alt_path]), - ); + let parent_variants = if is_cloud && !parent_path_str.ends_with('/') { + vec![parent_path_str.clone(), format!("{}/", parent_path_str)] } else { - // Local paths - exact match - query = - query.filter(entities::directory_paths::Column::Path.eq(&parent_path_str)); - } + vec![parent_path_str.clone()] + }; + + let query = entities::directory_paths::Entity::find() + .filter(entities::directory_paths::Column::Path.is_in(parent_variants.clone())); if let Ok(Some(dir_path_record)) = query.one(ctx.library_db()).await { // Found parent in database, cache it + ctx.log(format!("Found parent in database: id = {}", dir_path_record.entry_id)); state .entry_id_cache .insert(parent_path.to_path_buf(), dir_path_record.entry_id); @@ -259,7 +267,7 @@ impl EntryProcessor { "WARNING: Parent not found for {}: {} (tried: {:?})", entry.path.display(), parent_path.display(), - parent_with_slash.as_ref().unwrap_or(&parent_path_str) + parent_variants )); None } @@ -352,16 +360,9 @@ impl EntryProcessor { } // Cache the entry ID for potential children - // For directories, normalize the path by removing trailing slash for consistent lookups - // since PathBuf::parent() doesn't preserve trailing slashes + // Normalize cloud directory paths to match what parent() returns let cache_key = if entry.kind == EntryKind::Directory { - let path_str = entry.path.to_string_lossy(); - if path_str.ends_with('/') && path_str.contains("://") { - // Cloud directory path - remove trailing slash for cache consistency - PathBuf::from(path_str.trim_end_matches('/')) - } else { - entry.path.clone() - } + normalize_cloud_dir_path(&entry.path) } else { entry.path.clone() }; diff --git a/core/src/ops/indexing/job.rs b/core/src/ops/indexing/job.rs index 6087b2f38..d5ea4accc 100644 --- a/core/src/ops/indexing/job.rs +++ b/core/src/ops/indexing/job.rs @@ -385,6 +385,13 @@ impl JobHandler for IndexerJob { warn!("DEBUG: IndexerJob entering phase: {:?}", current_phase); match current_phase { Phase::Discovery => { + // For cloud volumes, construct the base URL for building absolute paths + let cloud_url_base = if let Some((service, identifier, _)) = self.config.path.as_cloud() { + Some(format!("{}://{}/", service.scheme(), identifier)) + } else { + None + }; + // Use scope-aware discovery if self.config.is_current_scope() { Self::run_current_scope_discovery_static(state, &ctx, root_path).await?; @@ -395,6 +402,7 @@ impl JobHandler for IndexerJob { root_path, self.config.rule_toggles.clone(), volume_backend.as_ref(), + cloud_url_base, ) .await?; } diff --git a/core/src/ops/indexing/phases/discovery.rs b/core/src/ops/indexing/phases/discovery.rs index f95a97da0..549537ac8 100644 --- a/core/src/ops/indexing/phases/discovery.rs +++ b/core/src/ops/indexing/phases/discovery.rs @@ -9,6 +9,7 @@ use crate::{ state::{DirEntry, EntryKind, IndexError, IndexPhase, IndexerProgress, IndexerState}, }, }; +use std::path::PathBuf; use std::time::Instant; use std::{path::Path, sync::Arc}; @@ -28,6 +29,7 @@ pub async fn run_discovery_phase( root_path: &Path, rule_toggles: RuleToggles, volume_backend: Option<&Arc>, + cloud_url_base: Option, ) -> Result<(), JobError> { ctx.log(format!( "Discovery phase starting from: {}", @@ -72,7 +74,7 @@ pub async fn run_discovery_phase( ctx.progress(Progress::generic(indexer_progress.to_generic_progress())); // Read directory entries with per-dir FS timing - match read_directory(&dir_path, volume_backend).await { + match read_directory(&dir_path, volume_backend, cloud_url_base.as_deref()).await { Ok(entries) => { let entry_count = entries.len(); let mut added_count = 0; @@ -186,6 +188,7 @@ pub async fn run_discovery_phase( async fn read_directory( path: &Path, volume_backend: Option<&Arc>, + cloud_url_base: Option<&str>, ) -> Result, std::io::Error> { // Use provided backend or create LocalBackend fallback let backend: Arc = match volume_backend { @@ -199,13 +202,14 @@ async fn read_directory( } }; - read_directory_with_backend(backend.as_ref(), path).await + read_directory_with_backend(backend.as_ref(), path, cloud_url_base).await } /// Read a directory using a volume backend (local or cloud) async fn read_directory_with_backend( backend: &dyn crate::volume::VolumeBackend, path: &Path, + cloud_url_base: Option<&str>, ) -> Result, std::io::Error> { let t_rd_start = Instant::now(); @@ -217,12 +221,29 @@ async fn read_directory_with_backend( // Convert RawDirEntry to DirEntry let entries: Vec = raw_entries .into_iter() - .map(|raw| DirEntry { - path: path.join(&raw.name), - kind: raw.kind, - size: raw.size, - modified: raw.modified, - inode: raw.inode, + .map(|raw| { + // For cloud volumes, prepend the cloud URL base to build proper hierarchical paths + let full_path = if let Some(base) = cloud_url_base { + // Cloud: s3://bucket/ + relative_path + filename + let relative = path.to_string_lossy(); + let joined = if relative.is_empty() { + raw.name.clone() + } else { + format!("{}/{}", relative.trim_end_matches('/'), raw.name) + }; + PathBuf::from(format!("{}{}", base, joined)) + } else { + // Local: just join normally + path.join(&raw.name) + }; + + DirEntry { + path: full_path, + kind: raw.kind, + size: raw.size, + modified: raw.modified, + inode: raw.inode, + } }) .collect(); diff --git a/core/src/ops/network/revoke/action.rs b/core/src/ops/network/revoke/action.rs index 59fbdaed1..c7dc5234e 100644 --- a/core/src/ops/network/revoke/action.rs +++ b/core/src/ops/network/revoke/action.rs @@ -24,24 +24,12 @@ impl CoreAction for DeviceRevokeAction { .get_networking() .await .ok_or_else(|| ActionError::Internal("Networking not initialized".to_string()))?; - // Remove from registry state + // Remove from registry state and persistence { let reg = net.device_registry(); let mut guard = reg.write().await; let _ = guard.remove_device(self.device_id); - } - // Remove from persistence - { - let reg = net.device_registry(); - let guard = reg.read().await; - // DevicePersistence is not exposed; reconstruct persistence with same data_dir - // Use the same constructor used in registry - let persistence = crate::service::network::device::DevicePersistence::new( - crate::config::default_data_dir() - .map_err(|e| ActionError::Internal(e.to_string()))?, - ) - .map_err(|e| ActionError::Internal(e.to_string()))?; - let _ = persistence.remove_paired_device(self.device_id).await; + let _ = guard.remove_paired_device(self.device_id).await; } Ok(DeviceRevokeOutput { revoked: true }) } diff --git a/core/src/service/network/device/persistence.rs b/core/src/service/network/device/persistence.rs index e7cd7c7f1..fee28a25c 100644 --- a/core/src/service/network/device/persistence.rs +++ b/core/src/service/network/device/persistence.rs @@ -1,18 +1,12 @@ //! Persistence for paired devices and their connection info use super::{DeviceInfo, SessionKeys}; +use crate::crypto::key_manager::KeyManager; use crate::service::network::{NetworkingError, Result}; -use aes_gcm::{ - aead::{Aead, AeadCore, KeyInit, OsRng}, - Aes256Gcm, Key, Nonce, -}; use chrono::{DateTime, Utc}; -use hkdf::Hkdf; use serde::{Deserialize, Serialize}; -use sha2::Sha256; use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use tokio::fs; +use std::sync::Arc; use uuid::Uuid; /// Persisted paired device data (plain data structure) @@ -29,17 +23,6 @@ pub struct PersistedPairedDevice { pub relay_url: Option, } -/// Encrypted device data for disk storage -#[derive(Debug, Serialize, Deserialize)] -struct EncryptedDeviceData { - /// Encrypted device data using AES-256-GCM - ciphertext: Vec, - /// Nonce for AES-GCM encryption - nonce: Vec, - /// Salt used for key derivation - salt: Vec, -} - /// Trust level for persistent connections #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TrustLevel { @@ -57,188 +40,94 @@ impl Default for TrustLevel { } } -/// Collection of all paired devices (encrypted on disk) -#[derive(Debug, Serialize, Deserialize)] -struct PersistedPairedDevices { - devices: HashMap, - last_saved: DateTime, - /// Version for future migration support - version: u32, -} - /// Device persistence manager pub struct DevicePersistence { - data_dir: PathBuf, - devices_file: PathBuf, - device_key: [u8; 32], + key_manager: Arc, } impl DevicePersistence { /// Create a new device persistence manager - pub fn new(data_dir: impl AsRef) -> Result { - let data_dir = data_dir.as_ref().to_path_buf(); - let networking_dir = data_dir.join("networking"); - let devices_file = networking_dir.join("paired_devices.json"); - - // Load device key from fallback file (consistent with DeviceManager) - let master_key_path = data_dir.join("master_key"); - let device_key = load_or_create_device_key(&master_key_path) - .map_err(|e| NetworkingError::Protocol(format!("Failed to load device key: {}", e)))?; - - Ok(Self { - data_dir: networking_dir, - devices_file, - device_key, - }) + pub fn new(key_manager: Arc) -> Self { + Self { key_manager } } - #[cfg(test)] - /// Create a test persistence manager with a fixed key (for testing only) - pub fn new_for_test(data_dir: impl AsRef) -> Result { - // Just use the regular new() method for tests now - // The fallback file will ensure consistency across test runs - Self::new(data_dir) + /// Generate key for a paired device + fn device_key(device_id: Uuid) -> String { + format!("paired_device_{}", device_id) } - /// Derive encryption key from master key for device persistence - fn derive_encryption_key(&self, salt: &[u8]) -> Result<[u8; 32]> { - let master_key = &self.device_key; + /// Key for the list of all paired device IDs + const DEVICE_LIST_KEY: &'static str = "paired_devices_list"; - let hk = Hkdf::::new(Some(salt), master_key); - let mut derived_key = [0u8; 32]; - hk.expand(b"spacedrive-device-persistence", &mut derived_key) - .map_err(|e| NetworkingError::Protocol(format!("Key derivation failed: {}", e)))?; - - Ok(derived_key) + /// Get list of paired device IDs + async fn get_device_list(&self) -> Result> { + match self.key_manager.get_secret(Self::DEVICE_LIST_KEY).await { + Ok(data) => { + let device_ids: Vec = + serde_json::from_slice(&data).map_err(|e| NetworkingError::Serialization(e))?; + Ok(device_ids) + } + Err(_) => Ok(Vec::new()), + } } - /// Encrypt device data using master key-derived encryption key - fn encrypt_device_data(&self, device: &PersistedPairedDevice) -> Result { - // Generate random salt for key derivation - let mut salt = [0u8; 32]; - aes_gcm::aead::rand_core::RngCore::fill_bytes(&mut OsRng, &mut salt); - - // Derive encryption key - let encryption_key = self.derive_encryption_key(&salt)?; - let key = Key::::from_slice(&encryption_key); - let cipher = Aes256Gcm::new(key); - - // Generate nonce - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - - // Serialize and encrypt device data - let plaintext = - serde_json::to_vec(device).map_err(|e| NetworkingError::Serialization(e))?; - - let ciphertext = cipher - .encrypt(&nonce, plaintext.as_ref()) - .map_err(|e| NetworkingError::Protocol(format!("Encryption failed: {}", e)))?; - - Ok(EncryptedDeviceData { - ciphertext, - nonce: nonce.to_vec(), - salt: salt.to_vec(), - }) + /// Save list of paired device IDs + async fn save_device_list(&self, device_ids: &[Uuid]) -> Result<()> { + let data = + serde_json::to_vec(device_ids).map_err(|e| NetworkingError::Serialization(e))?; + self.key_manager + .set_secret(Self::DEVICE_LIST_KEY, &data) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to save device list: {}", e)))?; + Ok(()) } - /// Decrypt device data using master key-derived encryption key - fn decrypt_device_data( - &self, - encrypted_data: &EncryptedDeviceData, - ) -> Result { - // Derive encryption key using stored salt - let encryption_key = self.derive_encryption_key(&encrypted_data.salt)?; - let key = Key::::from_slice(&encryption_key); - let cipher = Aes256Gcm::new(key); - - // Decrypt data - let nonce = Nonce::from_slice(&encrypted_data.nonce); - let plaintext = cipher - .decrypt(nonce, encrypted_data.ciphertext.as_ref()) - .map_err(|e| NetworkingError::Protocol(format!("Decryption failed: {}", e)))?; - - // Deserialize device data - let device: PersistedPairedDevice = - serde_json::from_slice(&plaintext).map_err(|e| NetworkingError::Serialization(e))?; - - Ok(device) - } - - /// Save paired devices to disk (encrypted) + /// Save paired devices to key manager (encrypted) pub async fn save_paired_devices( &self, devices: &HashMap, ) -> Result<()> { - // Ensure data directory exists - if let Some(parent) = self.devices_file.parent() { - fs::create_dir_all(parent) - .await - .map_err(NetworkingError::Io)?; - } + let device_ids: Vec = devices.keys().copied().collect(); + self.save_device_list(&device_ids).await?; - // Encrypt each device individually - let mut encrypted_devices = HashMap::new(); for (device_id, device) in devices { - let encrypted_data = self.encrypt_device_data(device)?; - encrypted_devices.insert(*device_id, encrypted_data); + let key = Self::device_key(*device_id); + let data = + serde_json::to_vec(device).map_err(|e| NetworkingError::Serialization(e))?; + self.key_manager + .set_secret(&key, &data) + .await + .map_err(|e| { + NetworkingError::Protocol(format!("Failed to save device {}: {}", device_id, e)) + })?; } - let persisted = PersistedPairedDevices { - devices: encrypted_devices, - last_saved: Utc::now(), - version: 1, // Current version - }; - - // Write to temporary file first, then rename for atomic operation - let temp_file = self.devices_file.with_extension("tmp"); - let json_data = serde_json::to_string_pretty(&persisted) - .map_err(|e| NetworkingError::Serialization(e))?; - - fs::write(&temp_file, json_data) - .await - .map_err(NetworkingError::Io)?; - fs::rename(&temp_file, &self.devices_file) - .await - .map_err(NetworkingError::Io)?; - println!("Saved {} paired devices (encrypted)", devices.len()); Ok(()) } - /// Load paired devices from disk (decrypt) + /// Load paired devices from key manager (decrypt) pub async fn load_paired_devices(&self) -> Result> { - if !self.devices_file.exists() { - return Ok(HashMap::new()); - } - - let json_data = fs::read_to_string(&self.devices_file) - .await - .map_err(NetworkingError::Io)?; - - let persisted: PersistedPairedDevices = - serde_json::from_str(&json_data).map_err(|e| NetworkingError::Serialization(e))?; - - // Check version compatibility - if persisted.version != 1 { - return Err(NetworkingError::Protocol(format!( - "Unsupported device persistence version: {}", - persisted.version - ))); - } - - // Decrypt each device individually + let device_ids = self.get_device_list().await?; let mut devices = HashMap::new(); - for (device_id, encrypted_data) in persisted.devices { - match self.decrypt_device_data(&encrypted_data) { - Ok(device) => { - // Filter out devices with expired session keys - if !device.session_keys.is_expired() { - devices.insert(device_id, device); + + for device_id in device_ids { + let key = Self::device_key(device_id); + match self.key_manager.get_secret(&key).await { + Ok(data) => { + match serde_json::from_slice::(&data) { + Ok(device) => { + if !device.session_keys.is_expired() { + devices.insert(device_id, device); + } + } + Err(e) => { + eprintln!("Failed to deserialize device {}: {}", device_id, e); + } } } Err(e) => { - eprintln!("Failed to decrypt device {}: {}", device_id, e); - // Continue loading other devices even if one fails + eprintln!("Failed to load device {}: {}", device_id, e); } } } @@ -378,30 +267,39 @@ impl DevicePersistence { /// Clear all paired devices pub async fn clear_all_devices(&self) -> Result<()> { - if self.devices_file.exists() { - fs::remove_file(&self.devices_file) - .await - .map_err(NetworkingError::Io)?; - } - Ok(()) - } + let device_ids = self.get_device_list().await?; - /// Get file path - pub fn devices_file_path(&self) -> &Path { - &self.devices_file + for device_id in device_ids { + let key = Self::device_key(device_id); + if let Err(e) = self.key_manager.delete_secret(&key).await { + eprintln!("Failed to delete device {}: {}", device_id, e); + } + } + + self.key_manager + .delete_secret(Self::DEVICE_LIST_KEY) + .await + .map_err(|e| NetworkingError::Protocol(format!("Failed to clear device list: {}", e)))?; + + Ok(()) } } #[cfg(test)] mod tests { use super::*; + use crate::crypto::key_manager::KeyManager; use crate::service::network::utils::identity::NetworkFingerprint; use tempfile::TempDir; async fn create_test_persistence() -> (DevicePersistence, TempDir) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let persistence = DevicePersistence::new_for_test(temp_dir.path()) - .expect("Failed to create test persistence"); + let device_key_fallback = temp_dir.path().join("device_key"); + let key_manager = Arc::new( + KeyManager::new_with_fallback(temp_dir.path().to_path_buf(), Some(device_key_fallback)) + .expect("Failed to create key manager"), + ); + let persistence = DevicePersistence::new(key_manager); (persistence, temp_dir) } @@ -516,62 +414,4 @@ mod tests { ); } - #[tokio::test] - async fn test_file_encryption_format() { - let (persistence, temp_dir) = create_test_persistence().await; - - let device_id = Uuid::new_v4(); - let device_info = create_test_device_info(); - let session_keys = SessionKeys::from_shared_secret(vec![1, 2, 3, 4]); - - // Add device - persistence - .add_paired_device(device_id, device_info, session_keys, None) - .await - .unwrap(); - - // Read the raw file - it should be encrypted (not contain plaintext session keys) - let file_path = temp_dir - .path() - .join("networking") - .join("paired_devices.json"); - let raw_content = tokio::fs::read_to_string(&file_path).await.unwrap(); - - println!("Raw file content: {}", raw_content); - - // The file should not contain the plaintext session key bytes - assert!(!raw_content.contains("\"shared_secret\":[1,2,3,4]")); - - // But should contain encrypted structure - assert!(raw_content.contains("\"ciphertext\"")); - assert!(raw_content.contains("\"nonce\"")); - assert!(raw_content.contains("\"salt\"")); - assert!(raw_content.contains("\"version\": 1")); - - println!("Device data is properly encrypted on disk"); - } -} - -/// Load device key from file, or create a new one -fn load_or_create_device_key(path: &PathBuf) -> std::io::Result<[u8; 32]> { - use rand::RngCore; - - // Try to load from file - if path.exists() { - let data = std::fs::read(path)?; - if data.len() == 32 { - let mut key = [0u8; 32]; - key.copy_from_slice(&data); - return Ok(key); - } - } - - // Create new key - let mut key = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut key); - - // Save to file - std::fs::write(path, &key)?; - - Ok(key) } diff --git a/core/src/service/network/device/registry.rs b/core/src/service/network/device/registry.rs index 1009fde49..2f758438a 100644 --- a/core/src/service/network/device/registry.rs +++ b/core/src/service/network/device/registry.rs @@ -4,12 +4,12 @@ use super::{ ConnectionInfo, DeviceInfo, DevicePersistence, DeviceState, PersistedPairedDevice, SessionKeys, TrustLevel, }; +use crate::crypto::key_manager::KeyManager; use crate::device::DeviceManager; use crate::service::network::{utils::logging::NetworkLogger, NetworkingError, Result}; use chrono::{DateTime, Utc}; use iroh::{NodeAddr, NodeId}; use std::collections::HashMap; -use std::path::Path; use std::sync::Arc; use uuid::Uuid; @@ -38,19 +38,19 @@ impl DeviceRegistry { /// Create a new device registry pub fn new( device_manager: Arc, - data_dir: impl AsRef, + key_manager: Arc, logger: Arc, - ) -> Result { - let persistence = DevicePersistence::new(data_dir)?; + ) -> Self { + let persistence = DevicePersistence::new(key_manager); - Ok(Self { + Self { device_manager, devices: HashMap::new(), node_to_device: HashMap::new(), session_to_device: HashMap::new(), persistence, logger, - }) + } } /// Load paired devices from persistence on startup @@ -416,6 +416,11 @@ impl DeviceRegistry { Ok(()) } + /// Remove a paired device from persistence + pub async fn remove_paired_device(&self, device_id: Uuid) -> Result { + self.persistence.remove_paired_device(device_id).await + } + /// Get peer ID for a device pub fn get_node_by_device(&self, device_id: Uuid) -> Option { // Look through node_to_device map in reverse diff --git a/docs/core/key-manager.mdx b/docs/core/key-manager.mdx new file mode 100644 index 000000000..f97a50510 --- /dev/null +++ b/docs/core/key-manager.mdx @@ -0,0 +1,394 @@ +--- +title: Key Manager +sidebarTitle: Key Manager +--- + +The KeyManager is Spacedrive's unified cryptographic secret storage system. It provides encrypted storage for all sensitive data including device keys, library encryption keys, paired device session keys, and cloud credentials. + +## Architecture + +### Storage Backend + +KeyManager uses **redb**, an embedded key-value database, for encrypted secret storage: + +- **Database**: `/secrets.redb` +- **Encryption**: XChaCha20-Poly1305 AEAD cipher +- **Root Key**: Device key stored in OS keychain (or plaintext file fallback for development and testing only) + +All secrets are encrypted at rest using the device key. The database provides ACID guarantees and transactional operations. + +### Device Key Hierarchy + +``` +Device Key (256-bit, from OS keychain) + │ + ├─ Library Keys (per-library encryption) + │ └─ Used by: Cloud credentials, library-specific secrets + │ + ├─ Paired Device Data (session keys, device info) + │ └─ Used by: P2P networking, device pairing + │ + └─ Arbitrary Secrets (application-level secrets) + └─ Used by: Extensions, custom storage needs +``` + +### Key Storage Locations + +**Device Key**: +1. **Primary**: OS Keychain + - macOS: Keychain Access (`Spacedrive` service) + - Linux: Secret Service API (GNOME Keyring, KWallet) + - Windows: Windows Credential Manager +2. **Fallback**: `/device_key` (development and testing only) + +**Encrypted Secrets**: +- All platforms: `/secrets.redb` + +## What's Stored + +### 1. Library Encryption Keys + +Each library has a unique encryption key used for encrypting library-specific secrets: + +```rust +// Automatically generated when first accessed +let library_key = key_manager.get_library_key(library_id).await?; +``` + +**Key format**: `library_{uuid}` + +**Used for**: +- Cloud credential encryption +- Library-specific secret storage +- Future: Library database encryption + +### 2. Paired Device Data + +Device pairing information and session keys for P2P communication: + +```rust +pub struct PersistedPairedDevice { + device_info: DeviceInfo, + session_keys: SessionKeys, + paired_at: DateTime, + last_connected_at: Option>, + trust_level: TrustLevel, + relay_url: Option, +} +``` + +**Key format**: `paired_device_{uuid}` + +**Includes**: +- Device identity (name, slug, type, OS) +- Session keys for encrypted communication +- Trust level (Trusted, Unreliable, Blocked) +- Connection metadata + +### 3. Cloud Credentials + +OAuth tokens and API keys for cloud storage integrations: + +```rust +pub struct CloudCredential { + volume_fingerprint: String, + encrypted_credential: Vec, // Encrypted with library key + service_type: String, // "google_drive", "dropbox", etc. +} +``` + +**Note**: Cloud credentials are stored in the library database, but encrypted using library keys from KeyManager. + +### 4. Arbitrary Secrets + +General-purpose encrypted storage for application or extension needs: + +```rust +// Store any secret +key_manager.set_secret("my_api_key", b"secret_value").await?; + +// Retrieve +let secret = key_manager.get_secret("my_api_key").await?; + +// Delete +key_manager.delete_secret("my_api_key").await?; +``` + +## Security Model + +### Encryption + +**Algorithm**: XChaCha20-Poly1305 +- **Cipher**: XChaCha20 (extended-nonce ChaCha20) +- **Authentication**: Poly1305 MAC +- **Nonce**: 24 bytes (randomly generated per encryption) +- **Key size**: 256 bits + +**Process**: +1. Generate random 24-byte nonce +2. Encrypt plaintext with device key and nonce +3. Compute authentication tag +4. Prepend nonce to ciphertext: `[nonce(24) | ciphertext | tag(16)]` + +### Device Key Protection + +The device key never exists in plaintext on disk (except in development with file fallback): + +**Production**: +``` +User's device + └─ OS Keychain (hardware-backed on supported devices) + └─ Device Key (256-bit) + └─ Protected by OS authentication (biometrics, password) +``` + +**Development and Testing Only**: +``` +/device_key (file fallback) + └─ Plaintext 32-byte file + └─ Protected only by OS file permissions + └─ Not recommended for production +``` + +### Key Rotation + + + Device key rotation is not currently supported. Rotating the device key would + require re-encrypting all secrets in the database. + + +Library keys are automatically generated and do not require manual rotation. + +## API Usage + +### Initialization + +The KeyManager is initialized once at application startup and shared via `Arc`: + +```rust +// In Core initialization +let device_key_fallback = data_dir.join("device_key"); +let key_manager = Arc::new(KeyManager::new_with_fallback( + data_dir.clone(), + Some(device_key_fallback), +)?); +``` + +### Basic Operations + +```rust +// Get device key +let device_key = key_manager.get_device_key().await?; + +// Get library key (auto-generates if not exists) +let library_key = key_manager.get_library_key(library_id).await?; + +// Store a secret +key_manager.set_secret("api_token", token_bytes).await?; + +// Retrieve a secret +let token = key_manager.get_secret("api_token").await?; + +// Delete a secret +key_manager.delete_secret("api_token").await?; + +// Close database (on shutdown) +key_manager.close().await?; +``` + +### Integration Examples + +**Device Manager** (network identity): +```rust +// DeviceManager uses KeyManager for device key +let device = DeviceManager::init(&data_dir, key_manager.clone(), None)?; +let device_key = device.master_key().await?; +let identity = NetworkIdentity::from_device_key(&device_key).await?; +``` + +**Device Pairing** (session keys): +```rust +// Store paired device +let persistence = DevicePersistence::new(key_manager.clone()); +persistence.add_paired_device( + device_id, + device_info, + session_keys, + relay_url, +).await?; + +// Load paired devices +let devices = persistence.load_paired_devices().await?; +``` + +**Cloud Credentials**: +```rust +// Encrypt and store cloud credential +let cloud_manager = CloudCredentialManager::new(library_id, key_manager.clone()); +cloud_manager.store_credential( + volume_fingerprint, + credential, + service_type, +).await?; +``` + +## Error Handling + +```rust +use sd_core::crypto::key_manager::KeyManagerError; + +match key_manager.get_secret("my_key").await { + Ok(secret) => println!("Secret: {:?}", secret), + Err(KeyManagerError::KeyNotFound(key)) => { + println!("Secret '{}' not found", key); + } + Err(KeyManagerError::EncryptionError(e)) => { + eprintln!("Encryption failed: {}", e); + } + Err(e) => eprintln!("KeyManager error: {}", e), +} +``` + +## Performance Considerations + +### Caching + +The device key is cached in memory after first retrieval to avoid repeated OS keychain access: + +```rust +// First call: retrieves from keychain +let key1 = key_manager.get_device_key().await?; + +// Subsequent calls: returns cached value (fast) +let key2 = key_manager.get_device_key().await?; +``` + +### Concurrent Access + +KeyManager uses async locking for safe concurrent access: + +```rust +// Safe to call from multiple tasks +let key_manager = Arc::new(KeyManager::new(data_dir)?); + +tokio::spawn({ + let km = key_manager.clone(); + async move { km.set_secret("key1", b"value").await } +}); + +tokio::spawn({ + let km = key_manager.clone(); + async move { km.get_secret("key2").await } +}); +``` + +### Database Size + +The redb database grows with the number of secrets stored. Typical sizes: + +- **Fresh install**: ~4KB (empty database) +- **With 10 paired devices**: ~20KB +- **With 100 secrets**: ~50KB + +The database is compacted automatically by redb. + +## Migration from Legacy Storage + + + Prior to the unified KeyManager, Spacedrive stored secrets in multiple locations: + - Device key: `/master_key` file + - Paired devices: `/networking/paired_devices.json` (AES-256-GCM encrypted) + - Cloud credentials: OS keychain (unreliable) + + These systems have been consolidated into KeyManager for better security and reliability. + + +## Troubleshooting + +### "Failed to access keychain" Error + +If KeyManager can't access the OS keychain: + +1. **macOS**: Grant Keychain Access permission in System Preferences +2. **Linux**: Install `gnome-keyring` or `kwallet` +3. **Fallback**: KeyManager will use file fallback at `/device_key` + +### Corrupted Database + +If `secrets.redb` becomes corrupted: + +```bash +# Backup first +cp ~/.spacedrive/secrets.redb ~/.spacedrive/secrets.redb.backup + +# Remove corrupted database (will lose paired devices!) +rm ~/.spacedrive/secrets.redb + +# Restart Spacedrive (new database will be created) +``` + + + Deleting `secrets.redb` will unpair all devices and remove cloud credentials. + You'll need to re-pair devices and re-authenticate cloud services. + + +### Inspecting Secrets (Development) + +```rust +// List all paired device keys +let db = key_manager.db.read().await; +let read_txn = db.begin_read()?; +let table = read_txn.open_table(SECRETS_TABLE)?; + +for item in table.iter()? { + let (key, _value) = item?; + if key.value().starts_with("paired_device_") { + println!("Device key: {}", key.value()); + } +} +``` + +## Security Best Practices + + + - **Always use OS keychain** in production (never file fallback) + - **Restrict file permissions** on `secrets.redb` (chmod 600) + - **Enable disk encryption** (FileVault, LUKS, BitLocker) + - **Backup device key** from keychain before system migration + + +### File Permissions + +Ensure strict permissions on sensitive files: + +```bash +# Check permissions +ls -la ~/.spacedrive/secrets.redb +ls -la ~/.spacedrive/device_key # If using fallback + +# Fix if needed +chmod 600 ~/.spacedrive/secrets.redb +chmod 600 ~/.spacedrive/device_key +``` + +### Backup Strategy + +The device key is critical for accessing all secrets: + +```bash +# macOS: Export device key from Keychain +security find-generic-password -s "Spacedrive" -a "device_key" -w + +# Linux: Backup entire secrets database +cp ~/.spacedrive/secrets.redb ~/backup/ + +# Windows: Export from Credential Manager +cmdkey /list | findstr Spacedrive +``` + +## Related Documentation + +- [Devices](/docs/core/devices) - Device identity and pairing +- [Networking](/docs/core/networking) - P2P communication using session keys +- [Cloud Integration](/docs/core/cloud-integration) - Cloud credential storage +- [Security](/docs/core/security) - Overall security architecture diff --git a/docs/core/networking.mdx b/docs/core/networking.mdx index 894e7f49d..3de1e0f58 100644 --- a/docs/core/networking.mdx +++ b/docs/core/networking.mdx @@ -607,22 +607,22 @@ pub enum NetworkError { ### State Persistence -Device relationships persist across restarts: +Device relationships and session keys are encrypted and persisted via the KeyManager: ```rust -// Saved to disk -pub struct PersistedNetworkState { - // Our identity - identity: NetworkIdentity, - - // Paired devices - paired_devices: Vec, - - // Relay preferences - preferred_relays: Vec, +// Paired device data stored per-device in KeyManager +pub struct PersistedPairedDevice { + device_info: DeviceInfo, + session_keys: SessionKeys, + paired_at: DateTime, + trust_level: TrustLevel, + relay_url: Option, } -// Location: ~/.spacedrive/network_state.json +// Storage: +// - Device key: OS keychain or encrypted fallback file +// - Paired devices: KeyManager's encrypted KV store (secrets.redb) +// - Network identity: Derived from device key ``` ## Future Development diff --git a/packages/interface/src/components/Explorer/components/AddStorageModal.tsx b/packages/interface/src/components/Explorer/components/AddStorageModal.tsx index f31b409eb..bcb5dbc2b 100644 --- a/packages/interface/src/components/Explorer/components/AddStorageModal.tsx +++ b/packages/interface/src/components/Explorer/components/AddStorageModal.tsx @@ -5,7 +5,6 @@ import { FolderOpen, HardDrive, CloudArrowUp, - ArrowLeft, } from "@phosphor-icons/react"; import { Button, @@ -255,6 +254,61 @@ const indexModes: Array<{ }, ]; +interface StorageDialogProps { + dialog: ReturnType; + form: any; + title: string; + icon: React.ReactNode; + description: React.ReactNode; + onSubmit?: any; + ctaLabel?: string; + loading?: boolean; + showBackButton?: boolean; + onBack?: () => void; + hideButtons?: boolean; + children: React.ReactNode; +} + +function StorageDialog({ + dialog, + form, + title, + icon, + description, + onSubmit, + ctaLabel, + loading, + showBackButton, + onBack, + hideButtons, + children, +}: StorageDialogProps) { + return ( + + Back + + ) : undefined + } + > + {children} + + ); +} + const jobOptions: JobOption[] = [ { id: "thumbnail", @@ -313,8 +367,6 @@ function AddStorageDialog(props: { useState(null); const [selectedProvider, setSelectedProvider] = useState(null); - const [selectedProtocol, setSelectedProtocol] = - useState(null); const [tab, setTab] = useState("preset"); const addLocation = useLibraryMutation("locations.add"); @@ -377,15 +429,7 @@ function AddStorageDialog(props: { const handleCategorySelect = (category: StorageCategory) => { setSelectedCategory(category); - if (category === "local") { - setStep("provider"); // Will show local folder UI - } else if (category === "cloud") { - setStep("provider"); - } else if (category === "network") { - setStep("provider"); - } else if (category === "external") { - setStep("provider"); - } + setStep("provider"); }; const handleProviderSelect = (provider: CloudProvider) => { @@ -394,13 +438,17 @@ function AddStorageDialog(props: { }; const handleBack = () => { - if (step === "provider" || step === "local-config" || step === "cloud-config") { + if (step === "cloud-config") { + setStep("provider"); + setSelectedProvider(null); + } else if (step === "local-config") { + setStep("provider"); + localForm.setValue("path", ""); + localForm.setValue("name", ""); + } else { setStep("category"); setSelectedCategory(null); setSelectedProvider(null); - setSelectedProtocol(null); - } else if (step === "cloud-config") { - setStep("provider"); } }; @@ -624,14 +672,12 @@ function AddStorageDialog(props: { // Render category selection if (step === "category") { return ( - } description="Choose the type of storage you want to connect" - className="w-[640px]" - onCancelled={true} hideButtons={true} >
@@ -658,28 +704,22 @@ function AddStorageDialog(props: { ))}
-
+ ); } // Render provider selection for cloud if (step === "provider" && selectedCategory === "cloud") { return ( - } description="Choose your cloud storage service" - className="w-[640px]" - onCancelled={true} hideButtons={true} - buttonsSideContent={ - - } + showBackButton={true} + onBack={handleBack} >
{cloudProviders.map((provider) => ( @@ -700,28 +740,22 @@ function AddStorageDialog(props: { ))}
-
+ ); } // Render provider selection for network if (step === "provider" && selectedCategory === "network") { return ( - } description="Choose your network file protocol" - className="w-[640px]" - onCancelled={true} hideButtons={true} - buttonsSideContent={ - - } + showBackButton={true} + onBack={handleBack} >
@@ -755,28 +789,22 @@ function AddStorageDialog(props: { ))}
-
+ ); } // Render provider selection for external if (step === "provider" && selectedCategory === "external") { return ( - } description="Select a connected drive to track" - className="w-[640px]" - onCancelled={true} hideButtons={true} - buttonsSideContent={ - - } + showBackButton={true} + onBack={handleBack} >
{volumes && volumes.length > 0 ? ( @@ -816,28 +844,22 @@ function AddStorageDialog(props: {
)} -
+ ); } // Render local folder configuration (browse + suggested + settings) if (step === "provider" && selectedCategory === "local") { return ( - } description="Choose a folder to index and manage" - className="w-[640px]" - onCancelled={true} hideButtons={true} - buttonsSideContent={ - - } + showBackButton={true} + onBack={handleBack} >
@@ -887,14 +909,14 @@ function AddStorageDialog(props: {
)}
-
+ ); } // Render local folder settings (after path selected) if (step === "local-config") { return ( - } description={localForm.watch("path")} ctaLabel="Add Location" - onCancelled={true} loading={addLocation.isPending} - className="w-[640px]" - buttonsSideContent={ - - } + showBackButton={true} + onBack={handleBack} >
@@ -1013,7 +1021,7 @@ function AddStorageDialog(props: {

)}
-
+ ); } @@ -1033,7 +1041,7 @@ function AddStorageDialog(props: { const isGCSType = provider.cloudServiceType === "gcs"; return ( - } description="Configure your cloud storage connection" ctaLabel="Add Storage" - onCancelled={true} loading={addCloudVolume.isPending} - className="w-[640px]" - buttonsSideContent={ - - } + showBackButton={true} + onBack={handleBack} > -
+
)}
-
+ ); } diff --git a/packages/ui/src/Dialog.tsx b/packages/ui/src/Dialog.tsx index fc6d9288b..5382fa1ed 100644 --- a/packages/ui/src/Dialog.tsx +++ b/packages/ui/src/Dialog.tsx @@ -362,7 +362,7 @@ export function Dialog({ {props.title} )} -
+
{props.description && ( {props.description} @@ -371,8 +371,7 @@ export function Dialog({ {props.children}
- {!props.hideButtons && - (submitButton || props.cancelBtn || onCancelled || props.buttonsSideContent) && ( + {(props.buttonsSideContent || (!props.hideButtons && (submitButton || props.cancelBtn || onCancelled))) && (
({
{props.buttonsSideContent}
)}
-
- {invertButtonFocus ? ( - <> - {submitButton} - {props.cancelBtn && cancelButton} - {onCancelled && closeButton} - - ) : ( - <> - {onCancelled && closeButton} - {props.cancelBtn && cancelButton} - {submitButton} - - )} -
+ {!props.hideButtons && ( +
+ {invertButtonFocus ? ( + <> + {submitButton} + {props.cancelBtn && cancelButton} + {onCancelled && closeButton} + + ) : ( + <> + {onCancelled && closeButton} + {props.cancelBtn && cancelButton} + {submitButton} + + )} +
+ )}
)}