feat: add cloud credential entity and migration

This commit is contained in:
Jamie Pine
2025-12-03 18:00:43 -08:00
parent cab3d4da18
commit 40d05fcec8
19 changed files with 811 additions and 437 deletions

View File

@@ -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

View File

Binary file not shown.

View File

@@ -3,7 +3,7 @@
//! Manages all encryption keys in Spacedrive:
//! - Device key: Stored in OS keychain (with file fallback)
//! - Library keys: Stored encrypted in redb database
//! - Cloud credentials: (future) Stored encrypted in redb database
//! - Cloud credentials: Stored encrypted in library database (not in key manager)
use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng},

View File

@@ -557,7 +557,7 @@ id = "application/sql"
name = "SQL"
extensions = ["sql"]
mime_types = ["application/sql", "text/x-sql"]
category = "document"
category = "database"
priority = 80
[file_types.metadata]
@@ -654,4 +654,4 @@ priority = 80
[file_types.metadata]
language = "astro"
text_file = true
framework = "astro"
framework = "astro"

View File

@@ -0,0 +1,26 @@
//! Cloud credential entity for encrypted storage of cloud service credentials
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "cloud_credentials")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique, indexed)]
pub volume_fingerprint: String,
pub encrypted_credential: Vec<u8>,
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 {}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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),
]
}
}

View File

@@ -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(),

View File

@@ -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()
};

View File

@@ -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?;
}

View File

@@ -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<dyn crate::volume::VolumeBackend>>,
cloud_url_base: Option<String>,
) -> 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<dyn crate::volume::VolumeBackend>>,
cloud_url_base: Option<&str>,
) -> Result<Vec<DirEntry>, std::io::Error> {
// Use provided backend or create LocalBackend fallback
let backend: Arc<dyn crate::volume::VolumeBackend> = 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<Vec<DirEntry>, 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<DirEntry> = 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();

View File

@@ -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 })
}

View File

@@ -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<String>,
}
/// Encrypted device data for disk storage
#[derive(Debug, Serialize, Deserialize)]
struct EncryptedDeviceData {
/// Encrypted device data using AES-256-GCM
ciphertext: Vec<u8>,
/// Nonce for AES-GCM encryption
nonce: Vec<u8>,
/// Salt used for key derivation
salt: Vec<u8>,
}
/// 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<Uuid, EncryptedDeviceData>,
last_saved: DateTime<Utc>,
/// 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<KeyManager>,
}
impl DevicePersistence {
/// Create a new device persistence manager
pub fn new(data_dir: impl AsRef<Path>) -> Result<Self> {
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<KeyManager>) -> 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<Path>) -> Result<Self> {
// 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::<Sha256>::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<Vec<Uuid>> {
match self.key_manager.get_secret(Self::DEVICE_LIST_KEY).await {
Ok(data) => {
let device_ids: Vec<Uuid> =
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<EncryptedDeviceData> {
// 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::<Aes256Gcm>::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<PersistedPairedDevice> {
// Derive encryption key using stored salt
let encryption_key = self.derive_encryption_key(&encrypted_data.salt)?;
let key = Key::<Aes256Gcm>::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<Uuid, PersistedPairedDevice>,
) -> 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<Uuid> = 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<HashMap<Uuid, PersistedPairedDevice>> {
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::<PersistedPairedDevice>(&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)
}

View File

@@ -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<DeviceManager>,
data_dir: impl AsRef<Path>,
key_manager: Arc<KeyManager>,
logger: Arc<dyn NetworkLogger>,
) -> Result<Self> {
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<bool> {
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<NodeId> {
// Look through node_to_device map in reverse

394
docs/core/key-manager.mdx Normal file
View File

@@ -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**: `<data_dir>/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**: `<data_dir>/device_key` (development and testing only)
**Encrypted Secrets**:
- All platforms: `<data_dir>/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<Utc>,
last_connected_at: Option<DateTime<Utc>>,
trust_level: TrustLevel,
relay_url: Option<String>,
}
```
**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<u8>, // 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**:
```
<data_dir>/device_key (file fallback)
└─ Plaintext 32-byte file
└─ Protected only by OS file permissions
└─ Not recommended for production
```
### Key Rotation
<Warning>
Device key rotation is not currently supported. Rotating the device key would
require re-encrypting all secrets in the database.
</Warning>
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
<Info>
Prior to the unified KeyManager, Spacedrive stored secrets in multiple locations:
- Device key: `<data_dir>/master_key` file
- Paired devices: `<data_dir>/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.
</Info>
## 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 `<data_dir>/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)
```
<Warning>
Deleting `secrets.redb` will unpair all devices and remove cloud credentials.
You'll need to re-pair devices and re-authenticate cloud services.
</Warning>
### 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
<Card title="Production Deployments">
- **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
</Card>
### 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

View File

@@ -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<PairedDevice>,
// Relay preferences
preferred_relays: Vec<Url>,
// Paired device data stored per-device in KeyManager
pub struct PersistedPairedDevice {
device_info: DeviceInfo,
session_keys: SessionKeys,
paired_at: DateTime<Utc>,
trust_level: TrustLevel,
relay_url: Option<String>,
}
// 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

View File

@@ -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<typeof useDialog>;
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 (
<Dialog
dialog={dialog}
form={form}
onSubmit={onSubmit}
title={title}
icon={icon}
description={description}
ctaLabel={ctaLabel}
onCancelled={true}
loading={loading}
formClassName="!min-w-[480px] !max-w-[480px] max-h-[80vh] flex flex-col"
hideButtons={hideButtons}
buttonsSideContent={
showBackButton ? (
<Button variant="gray" size="sm" onClick={onBack}>
Back
</Button>
) : undefined
}
>
{children}
</Dialog>
);
}
const jobOptions: JobOption[] = [
{
id: "thumbnail",
@@ -313,8 +367,6 @@ function AddStorageDialog(props: {
useState<StorageCategory | null>(null);
const [selectedProvider, setSelectedProvider] =
useState<CloudProvider | null>(null);
const [selectedProtocol, setSelectedProtocol] =
useState<NetworkProtocol | null>(null);
const [tab, setTab] = useState<SettingsTab>("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 (
<Dialog
<StorageDialog
dialog={dialog}
form={dummyForm}
title="Add Storage"
icon={<CloudArrowUp size={20} weight="fill" />}
description="Choose the type of storage you want to connect"
className="w-[640px]"
onCancelled={true}
hideButtons={true}
>
<div className="grid grid-cols-2 gap-3">
@@ -658,28 +704,22 @@ function AddStorageDialog(props: {
</button>
))}
</div>
</Dialog>
</StorageDialog>
);
}
// Render provider selection for cloud
if (step === "provider" && selectedCategory === "cloud") {
return (
<Dialog
<StorageDialog
dialog={dialog}
form={dummyForm}
title="Select Cloud Provider"
icon={<CloudArrowUp size={20} weight="fill" />}
description="Choose your cloud storage service"
className="w-[640px]"
onCancelled={true}
hideButtons={true}
buttonsSideContent={
<Button variant="gray" size="sm" onClick={handleBack}>
<ArrowLeft size={16} className="mr-1" />
Back
</Button>
}
showBackButton={true}
onBack={handleBack}
>
<div className="grid grid-cols-3 gap-3 max-h-[400px] overflow-y-auto pr-1">
{cloudProviders.map((provider) => (
@@ -700,28 +740,22 @@ function AddStorageDialog(props: {
</button>
))}
</div>
</Dialog>
</StorageDialog>
);
}
// Render provider selection for network
if (step === "provider" && selectedCategory === "network") {
return (
<Dialog
<StorageDialog
dialog={dialog}
form={dummyForm}
title="Select Network Protocol"
icon={<img src={ServerIcon} className="size-5" alt="" />}
description="Choose your network file protocol"
className="w-[640px]"
onCancelled={true}
hideButtons={true}
buttonsSideContent={
<Button variant="gray" size="sm" onClick={handleBack}>
<ArrowLeft size={16} className="mr-1" />
Back
</Button>
}
showBackButton={true}
onBack={handleBack}
>
<div className="space-y-3">
<div className="rounded-lg bg-accent/10 border border-accent/20 p-4 text-sm text-ink">
@@ -755,28 +789,22 @@ function AddStorageDialog(props: {
))}
</div>
</div>
</Dialog>
</StorageDialog>
);
}
// Render provider selection for external
if (step === "provider" && selectedCategory === "external") {
return (
<Dialog
<StorageDialog
dialog={dialog}
form={dummyForm}
title="Track External Drive"
icon={<HardDrive size={20} weight="fill" />}
description="Select a connected drive to track"
className="w-[640px]"
onCancelled={true}
hideButtons={true}
buttonsSideContent={
<Button variant="gray" size="sm" onClick={handleBack}>
<ArrowLeft size={16} className="mr-1" />
Back
</Button>
}
showBackButton={true}
onBack={handleBack}
>
<div className="space-y-3">
{volumes && volumes.length > 0 ? (
@@ -816,28 +844,22 @@ function AddStorageDialog(props: {
</div>
)}
</div>
</Dialog>
</StorageDialog>
);
}
// Render local folder configuration (browse + suggested + settings)
if (step === "provider" && selectedCategory === "local") {
return (
<Dialog
<StorageDialog
dialog={dialog}
form={dummyForm}
title="Add Local Folder"
icon={<Folder size={20} weight="fill" />}
description="Choose a folder to index and manage"
className="w-[640px]"
onCancelled={true}
hideButtons={true}
buttonsSideContent={
<Button variant="gray" size="sm" onClick={handleBack}>
<ArrowLeft size={16} className="mr-1" />
Back
</Button>
}
showBackButton={true}
onBack={handleBack}
>
<div className="space-y-4 flex flex-col">
<div className="space-y-2">
@@ -887,14 +909,14 @@ function AddStorageDialog(props: {
</div>
)}
</div>
</Dialog>
</StorageDialog>
);
}
// Render local folder settings (after path selected)
if (step === "local-config") {
return (
<Dialog
<StorageDialog
dialog={dialog}
form={localForm}
onSubmit={onSubmitLocal}
@@ -902,23 +924,9 @@ function AddStorageDialog(props: {
icon={<Folder size={20} weight="fill" />}
description={localForm.watch("path")}
ctaLabel="Add Location"
onCancelled={true}
loading={addLocation.isPending}
className="w-[640px]"
buttonsSideContent={
<Button
variant="gray"
size="sm"
onClick={() => {
setStep("provider");
localForm.setValue("path", "");
localForm.setValue("name", "");
}}
>
<ArrowLeft size={16} className="mr-1" />
Back
</Button>
}
showBackButton={true}
onBack={handleBack}
>
<div className="space-y-4">
<div className="space-y-2">
@@ -1013,7 +1021,7 @@ function AddStorageDialog(props: {
</p>
)}
</div>
</Dialog>
</StorageDialog>
);
}
@@ -1033,7 +1041,7 @@ function AddStorageDialog(props: {
const isGCSType = provider.cloudServiceType === "gcs";
return (
<Dialog
<StorageDialog
dialog={dialog}
form={cloudForm}
onSubmit={onSubmitCloud}
@@ -1041,17 +1049,11 @@ function AddStorageDialog(props: {
icon={<img src={provider.icon} className="size-5" alt="" />}
description="Configure your cloud storage connection"
ctaLabel="Add Storage"
onCancelled={true}
loading={addCloudVolume.isPending}
className="w-[640px]"
buttonsSideContent={
<Button variant="gray" size="sm" onClick={() => setStep("provider")}>
<ArrowLeft size={16} className="mr-1" />
Back
</Button>
}
showBackButton={true}
onBack={handleBack}
>
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-1">
<div className="space-y-4 h-full overflow-y-auto pr-1">
<div className="space-y-2">
<Label>Display Name</Label>
<Input
@@ -1262,7 +1264,7 @@ function AddStorageDialog(props: {
</p>
)}
</div>
</Dialog>
</StorageDialog>
);
}

View File

@@ -362,7 +362,7 @@ export function Dialog<S extends FieldValues>({
{props.title}
</RDialog.Title>
)}
<div className="p-5">
<div className="p-5 flex-1 overflow-auto">
{props.description && (
<RDialog.Description className="mb-2 text-sm text-ink-dull">
{props.description}
@@ -371,8 +371,7 @@ export function Dialog<S extends FieldValues>({
{props.children}
</div>
{!props.hideButtons &&
(submitButton || props.cancelBtn || onCancelled || props.buttonsSideContent) && (
{(props.buttonsSideContent || (!props.hideButtons && (submitButton || props.cancelBtn || onCancelled))) && (
<div
className={clsx(
"flex items-center justify-end space-x-2 border-t border-app-line bg-app-input/60 p-3",
@@ -383,26 +382,28 @@ export function Dialog<S extends FieldValues>({
<div>{props.buttonsSideContent}</div>
)}
<div className="grow" />
<div
className={clsx(
invertButtonFocus ? "flex-row-reverse" : "flex-row",
"flex gap-2",
)}
>
{invertButtonFocus ? (
<>
{submitButton}
{props.cancelBtn && cancelButton}
{onCancelled && closeButton}
</>
) : (
<>
{onCancelled && closeButton}
{props.cancelBtn && cancelButton}
{submitButton}
</>
)}
</div>
{!props.hideButtons && (
<div
className={clsx(
invertButtonFocus ? "flex-row-reverse" : "flex-row",
"flex gap-2",
)}
>
{invertButtonFocus ? (
<>
{submitButton}
{props.cancelBtn && cancelButton}
{onCancelled && closeButton}
</>
) : (
<>
{onCancelled && closeButton}
{props.cancelBtn && cancelButton}
{submitButton}
</>
)}
</div>
)}
</div>
)}
</Form>