mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 13:26:00 -04:00
feat: add cloud credential entity and migration
This commit is contained in:
@@ -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
|
||||
|
||||
BIN
core/:memory:
BIN
core/:memory:
Binary file not shown.
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
|
||||
26
core/src/infra/db/entities/cloud_credential.rs
Normal file
26
core/src/infra/db/entities/cloud_credential.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
394
docs/core/key-manager.mdx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user