diff --git a/crates/nvisy-cli/src/config/mod.rs b/crates/nvisy-cli/src/config/mod.rs index 4cbec0c..afa6977 100644 --- a/crates/nvisy-cli/src/config/mod.rs +++ b/crates/nvisy-cli/src/config/mod.rs @@ -169,7 +169,7 @@ impl Cli { service.postgres.into(), service.nats.into(), service.session_keys.into(), - service.master_key.into(), + service.crypto.into(), service.health.into(), webhook, ) diff --git a/crates/nvisy-cli/src/config/service.rs b/crates/nvisy-cli/src/config/service.rs index 5adcc8f..5d2fe67 100644 --- a/crates/nvisy-cli/src/config/service.rs +++ b/crates/nvisy-cli/src/config/service.rs @@ -9,7 +9,7 @@ use std::time::Duration; use clap::Args; use nvisy_nats::NatsConfig; use nvisy_postgres::PgConfig; -use nvisy_server::service::{HealthConfig, MasterKeyConfig, SessionKeysConfig}; +use nvisy_server::service::{CryptoConfig, HealthConfig, SessionKeysConfig}; /// Aggregated external-service arguments (database, NATS, auth keys). #[derive(Debug, Clone, Args)] @@ -28,7 +28,7 @@ pub struct ServiceArgs { /// Master encryption key path. #[clap(flatten)] - pub master_key: MasterKeyArgs, + pub crypto: CryptoArgs, /// Health monitoring configuration. #[clap(flatten)] @@ -152,9 +152,9 @@ impl From for SessionKeysConfig { } } -/// Master encryption key path arguments. +/// Encryption key path arguments. #[derive(Debug, Clone, Args)] -pub struct MasterKeyArgs { +pub struct CryptoArgs { /// File path to the 32-byte master encryption key. #[arg( long, @@ -164,8 +164,8 @@ pub struct MasterKeyArgs { pub key_path: PathBuf, } -impl From for MasterKeyConfig { - fn from(args: MasterKeyArgs) -> Self { +impl From for CryptoConfig { + fn from(args: CryptoArgs) -> Self { Self { key_path: args.key_path, } diff --git a/crates/nvisy-server/src/handler/connections.rs b/crates/nvisy-server/src/handler/connections.rs index c0fca8a..628a8f5 100644 --- a/crates/nvisy-server/src/handler/connections.rs +++ b/crates/nvisy-server/src/handler/connections.rs @@ -28,9 +28,8 @@ use crate::handler::request::{ WorkspacePathParams, }; use crate::handler::response::{Connection, ConnectionsPage, ErrorResponse}; -use crate::handler::{Error, ErrorKind, Result}; -use crate::service::crypto::encrypt_json; -use crate::service::{MasterKey, ServiceState}; +use crate::handler::{Error, Result}; +use crate::service::{CryptoService, ServiceState}; /// Tracing target for workspace connection operations. const TRACING_TARGET: &str = "nvisy_server::handler::connections"; @@ -48,7 +47,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::connections"; )] async fn create_connection( State(pg_client): State, - State(master_key): State, + State(crypto): State, AuthState(auth_state): AuthState, Path(path_params): Path, ValidateJson(request): ValidateJson, @@ -65,14 +64,7 @@ async fn create_connection( ) .await?; - let workspace_key = master_key.derive_workspace_key(path_params.workspace_id); - let encrypted_data = encrypt_json(&workspace_key, &request.data).map_err( - |e: crate::service::crypto::CryptoError| { - ErrorKind::InternalServerError - .with_message("Failed to encrypt connection data") - .with_context(e.to_string()) - }, - )?; + let encrypted_data = crypto.encrypt_json(path_params.workspace_id, &request.data)?; let new_connection = NewWorkspaceConnection { workspace_id: path_params.workspace_id, @@ -233,7 +225,7 @@ fn read_connection_docs(op: TransformOperation) -> TransformOperation { )] async fn update_connection( State(pg_client): State, - State(master_key): State, + State(crypto): State, AuthState(auth_state): AuthState, Path(path_params): Path, ValidateJson(request): ValidateJson, @@ -255,14 +247,7 @@ async fn update_connection( let encrypted_data = request .data - .map(|data| { - let workspace_key = master_key.derive_workspace_key(existing.workspace_id); - encrypt_json(&workspace_key, &data).map_err(|e: crate::service::crypto::CryptoError| { - ErrorKind::InternalServerError - .with_message("Failed to encrypt connection data") - .with_context(e.to_string()) - }) - }) + .map(|data| crypto.encrypt_json(existing.workspace_id, &data)) .transpose()?; let update_data = UpdateWorkspaceConnection { diff --git a/crates/nvisy-server/src/handler/contexts.rs b/crates/nvisy-server/src/handler/contexts.rs index 07b0816..cfca9e9 100644 --- a/crates/nvisy-server/src/handler/contexts.rs +++ b/crates/nvisy-server/src/handler/contexts.rs @@ -22,8 +22,7 @@ use crate::handler::request::{ContextPathParams, CursorPagination, WorkspacePath use crate::handler::response::{Context, ContextsPage, ErrorResponse}; use crate::handler::{Error, ErrorKind, Result}; use crate::middleware::DEFAULT_MAX_FILE_BODY_SIZE; -use crate::service::crypto::encrypt; -use crate::service::{MasterKey, ServiceState}; +use crate::service::{CryptoService, ServiceState}; /// Tracing target for workspace context operations. const TRACING_TARGET: &str = "nvisy_server::handler::contexts"; @@ -44,7 +43,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::contexts"; async fn create_context( State(pg_client): State, State(nats_client): State, - State(master_key): State, + State(crypto): State, AuthState(auth_state): AuthState, Path(path_params): Path, Multipart(mut multipart): Multipart, @@ -119,14 +118,8 @@ async fn create_context( let content = file_content .ok_or_else(|| ErrorKind::BadRequest.with_message("Missing required 'file' field"))?; - // Encrypt the content with workspace-derived key - let workspace_key = master_key.derive_workspace_key(path_params.workspace_id); - let encrypted_content = - encrypt(&workspace_key, &content).map_err(|e: crate::service::crypto::CryptoError| { - ErrorKind::InternalServerError - .with_message("Failed to encrypt context content") - .with_context(e.to_string()) - })?; + // Encrypt the content with the workspace-derived key + let encrypted_content = crypto.encrypt(path_params.workspace_id, &content)?; // Generate the object store key let context_id = Uuid::now_v7(); @@ -288,7 +281,7 @@ fn read_context_docs(op: TransformOperation) -> TransformOperation { async fn update_context( State(pg_client): State, State(nats_client): State, - State(master_key): State, + State(crypto): State, AuthState(auth_state): AuthState, Path(path_params): Path, Multipart(mut multipart): Multipart, @@ -357,14 +350,7 @@ async fn update_context( // If file content was provided, encrypt and store new content if let Some(content) = file_content { - let workspace_key = master_key.derive_workspace_key(existing.workspace_id); - let encrypted_content = encrypt(&workspace_key, &content).map_err( - |e: crate::service::crypto::CryptoError| { - ErrorKind::InternalServerError - .with_message("Failed to encrypt context content") - .with_context(e.to_string()) - }, - )?; + let encrypted_content = crypto.encrypt(existing.workspace_id, &content)?; let context_key = ContextKey::new(existing.workspace_id, existing.id); let context_store = nats_client diff --git a/crates/nvisy-server/src/handler/error/crypto_error.rs b/crates/nvisy-server/src/handler/error/crypto_error.rs new file mode 100644 index 0000000..dfcfbbe --- /dev/null +++ b/crates/nvisy-server/src/handler/error/crypto_error.rs @@ -0,0 +1,25 @@ +//! Crypto error to HTTP error conversion. +//! +//! Converts a [`CryptoError`] into the handler HTTP error. Encryption and +//! decryption failures are internal faults, so they map to a 500 with the +//! crypto error attached as context. + +use super::http_error::{Error as HttpError, ErrorKind}; +use crate::service::crypto::CryptoError; + +/// Tracing target for crypto error conversions. +const TRACING_TARGET: &str = "nvisy_server::handler::crypto"; + +impl From for HttpError<'static> { + fn from(error: CryptoError) -> Self { + tracing::error!( + target: TRACING_TARGET, + error = %error, + "Cryptographic operation failed" + ); + + ErrorKind::InternalServerError + .with_message("Cryptographic operation failed") + .with_context(error.to_string()) + } +} diff --git a/crates/nvisy-server/src/handler/error/mod.rs b/crates/nvisy-server/src/handler/error/mod.rs index 341db7f..590d583 100644 --- a/crates/nvisy-server/src/handler/error/mod.rs +++ b/crates/nvisy-server/src/handler/error/mod.rs @@ -1,5 +1,6 @@ //! [`Error`], [`ErrorKind`] and [`Result`]. +mod crypto_error; mod http_error; mod nats_error; mod pg_account; diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index 979f20d..b283ab6 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -123,11 +123,11 @@ mod test { use nvisy_webhook::reqwest::ReqwestClient; use crate::handler::{CustomRoutes, routes}; - use crate::service::{HealthConfig, MasterKeyConfig, ServiceState, SessionKeysConfig}; + use crate::service::{CryptoConfig, HealthConfig, ServiceState, SessionKeysConfig}; /// Builds the service sub-configs from the environment for integration tests. - fn configs_from_env() - -> anyhow::Result<(PgConfig, NatsConfig, SessionKeysConfig, MasterKeyConfig)> { + fn configs_from_env() -> anyhow::Result<(PgConfig, NatsConfig, SessionKeysConfig, CryptoConfig)> + { dotenvy::dotenv().ok(); let var = std::env::var; @@ -143,24 +143,24 @@ mod test { encoding_key: var("AUTH_PRIVATE_PEM_FILEPATH")?.into(), }; - let master_key = MasterKeyConfig { + let crypto = CryptoConfig { key_path: var("ENCRYPTION_KEY_FILEPATH")?.into(), }; - Ok((postgres, nats, session, master_key)) + Ok((postgres, nats, session, crypto)) } /// Returns a new [`TestServer`] with the given router. pub async fn create_test_server_with_router( router: impl Fn(ServiceState) -> ApiRouter, ) -> anyhow::Result { - let (postgres, nats, session, master_key) = configs_from_env()?; + let (postgres, nats, session, crypto) = configs_from_env()?; let webhook_service = ReqwestClient::default().into_service(); let state = ServiceState::from_config( postgres, nats, session, - master_key, + crypto, HealthConfig::default(), webhook_service, ) diff --git a/crates/nvisy-server/src/service/crypto/key.rs b/crates/nvisy-server/src/service/crypto/key.rs index 1f934f9..8bb943c 100644 --- a/crates/nvisy-server/src/service/crypto/key.rs +++ b/crates/nvisy-server/src/service/crypto/key.rs @@ -3,6 +3,7 @@ use std::fmt; use hkdf::Hkdf; +#[cfg(test)] use rand::Rng; use sha2::Sha256; use uuid::Uuid; @@ -38,6 +39,7 @@ impl EncryptionKey { } /// Generates a new random encryption key using a cryptographically secure RNG. + #[cfg(test)] #[must_use] pub fn generate() -> Self { let mut bytes = [0u8; KEY_SIZE]; @@ -52,13 +54,6 @@ impl EncryptionKey { &self.bytes } - /// Consumes the key and returns the raw bytes. - #[inline] - #[must_use] - pub fn into_bytes(self) -> [u8; KEY_SIZE] { - self.bytes - } - /// Derives a workspace-specific encryption key using HKDF-SHA256. /// /// This creates a unique encryption key for each workspace by combining diff --git a/crates/nvisy-server/src/service/crypto/mod.rs b/crates/nvisy-server/src/service/crypto/mod.rs index 6caf87b..8aef833 100644 --- a/crates/nvisy-server/src/service/crypto/mod.rs +++ b/crates/nvisy-server/src/service/crypto/mod.rs @@ -6,7 +6,9 @@ mod cipher; mod error; mod key; +mod service; -pub use cipher::{decrypt, decrypt_json, encrypt, encrypt_json}; +pub(crate) use cipher::{decrypt, decrypt_json, encrypt, encrypt_json}; pub use error::{CryptoError, CryptoResult}; -pub use key::EncryptionKey; +pub(crate) use key::EncryptionKey; +pub use service::{CryptoConfig, CryptoService}; diff --git a/crates/nvisy-server/src/service/crypto/service.rs b/crates/nvisy-server/src/service/crypto/service.rs new file mode 100644 index 0000000..eca98d2 --- /dev/null +++ b/crates/nvisy-server/src/service/crypto/service.rs @@ -0,0 +1,225 @@ +//! Encryption service backed by a file-loaded master key. +//! +//! [`CryptoService`] loads the master key once at startup and derives a +//! per-workspace key (HKDF-SHA256) for each operation, so the master key is +//! never used directly for workspace data. Master-scoped variants are also +//! available for data not tied to a workspace. + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use uuid::Uuid; + +use super::{CryptoResult, EncryptionKey, decrypt, decrypt_json, encrypt, encrypt_json}; +use crate::{Error, Result}; + +/// Tracing target for crypto service operations. +const TRACING_TARGET: &str = "nvisy_server::crypto"; + +/// Master encryption key file path configuration. +#[derive(Debug, Clone)] +pub struct CryptoConfig { + /// File path to the 32-byte master encryption key. + pub key_path: PathBuf, +} + +impl Default for CryptoConfig { + fn default() -> Self { + Self { + key_path: "./encryption.key".into(), + } + } +} + +/// Workspace-aware encryption service. +/// +/// Holds the master key and derives per-workspace keys on demand. Cheap to +/// clone (the key is shared through an `Arc`). +#[derive(Clone)] +pub struct CryptoService { + master_key: Arc, +} + +impl CryptoService { + /// Loads the master key from the path specified in `config`. + /// + /// The file must contain exactly 32 raw bytes (256-bit key). + pub async fn from_config(config: &CryptoConfig) -> Result { + Self::load(&config.key_path).await + } + + /// Loads the master key from a file path. + /// + /// The file must contain exactly 32 raw bytes (256-bit key). + pub async fn from_key_file(key_path: impl AsRef) -> Result { + Self::load(key_path.as_ref()).await + } + + /// Encrypts a serializable value under the given workspace's key. + pub fn encrypt_json( + &self, + workspace_id: Uuid, + value: &T, + ) -> CryptoResult> { + encrypt_json(&self.workspace_key(workspace_id), value) + } + + /// Decrypts a value previously encrypted under the given workspace's key. + pub fn decrypt_json( + &self, + workspace_id: Uuid, + ciphertext: &[u8], + ) -> CryptoResult { + decrypt_json(&self.workspace_key(workspace_id), ciphertext) + } + + /// Encrypts raw bytes under the given workspace's key. + pub fn encrypt(&self, workspace_id: Uuid, plaintext: &[u8]) -> CryptoResult> { + encrypt(&self.workspace_key(workspace_id), plaintext) + } + + /// Decrypts raw bytes previously encrypted under the given workspace's key. + pub fn decrypt(&self, workspace_id: Uuid, ciphertext: &[u8]) -> CryptoResult> { + decrypt(&self.workspace_key(workspace_id), ciphertext) + } + + /// Encrypts a serializable value under the master key (not workspace-scoped). + pub fn encrypt_json_master(&self, value: &T) -> CryptoResult> { + encrypt_json(&self.master_key, value) + } + + /// Decrypts a value previously encrypted under the master key. + pub fn decrypt_json_master(&self, ciphertext: &[u8]) -> CryptoResult { + decrypt_json(&self.master_key, ciphertext) + } + + /// Encrypts raw bytes under the master key (not workspace-scoped). + pub fn encrypt_master(&self, plaintext: &[u8]) -> CryptoResult> { + encrypt(&self.master_key, plaintext) + } + + /// Decrypts raw bytes previously encrypted under the master key. + pub fn decrypt_master(&self, ciphertext: &[u8]) -> CryptoResult> { + decrypt(&self.master_key, ciphertext) + } + + /// Derives the per-workspace key via HKDF-SHA256. + #[inline] + fn workspace_key(&self, workspace_id: Uuid) -> EncryptionKey { + self.master_key.derive_workspace_key(workspace_id) + } + + /// Reads and parses the 32-byte master key from disk. + async fn load(path: &Path) -> Result { + if !path.exists() { + return Err(Error::config("Encryption key file does not exist")); + } + if !path.is_file() { + return Err(Error::config("Encryption key path is not a file")); + } + + tracing::debug!( + target: TRACING_TARGET, + path = %path.display(), + "Loading master encryption key", + ); + + let bytes = tokio::fs::read(path).await.map_err(|e| { + tracing::error!( + target: TRACING_TARGET, + path = %path.display(), + error = %e, + "Failed to read encryption key file", + ); + Error::file_system("Failed to read encryption key file").with_source(e) + })?; + + let key = EncryptionKey::from_bytes(&bytes).map_err(|e| { + tracing::error!( + target: TRACING_TARGET, + path = %path.display(), + error = %e, + "Invalid encryption key: expected exactly 32 bytes", + ); + Error::config("Invalid encryption key: expected exactly 32 bytes").with_source(e) + })?; + + tracing::info!(target: TRACING_TARGET, "Master encryption key loaded"); + + Ok(Self { + master_key: Arc::new(key), + }) + } +} + +impl fmt::Debug for CryptoService { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CryptoService") + .field("master_key", &"[REDACTED]") + .finish() + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use serde::Deserialize; + use tempfile::TempDir; + + use super::*; + + async fn service_with_key(raw: [u8; 32]) -> CryptoService { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("encryption.key"); + fs::write(&key_path, raw).unwrap(); + // Keep the temp dir alive for the duration of the load. + let service = CryptoService::from_key_file(&key_path).await.unwrap(); + drop(temp_dir); + service + } + + #[tokio::test] + async fn reject_invalid_key_length() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("encryption.key"); + fs::write(&key_path, [0u8; 16]).unwrap(); + assert!(CryptoService::from_key_file(&key_path).await.is_err()); + } + + #[tokio::test] + async fn reject_missing_file() { + let temp_dir = TempDir::new().unwrap(); + let key_path = temp_dir.path().join("nonexistent.key"); + assert!(CryptoService::from_key_file(&key_path).await.is_err()); + } + + #[tokio::test] + async fn workspace_roundtrip() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Secret { + token: String, + } + + let crypto = service_with_key([0x42; 32]).await; + let workspace_id = Uuid::new_v4(); + let secret = Secret { + token: "s3cr3t".to_owned(), + }; + + let ciphertext = crypto.encrypt_json(workspace_id, &secret).unwrap(); + let decrypted: Secret = crypto.decrypt_json(workspace_id, &ciphertext).unwrap(); + assert_eq!(decrypted, secret); + } + + #[tokio::test] + async fn other_workspace_cannot_decrypt() { + let crypto = service_with_key([0x42; 32]).await; + let ciphertext = crypto.encrypt(Uuid::new_v4(), b"data").unwrap(); + let result = crypto.decrypt(Uuid::new_v4(), &ciphertext); + assert!(result.is_err()); + } +} diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index 6ec0824..26b8e47 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -12,10 +12,10 @@ use nvisy_nats::{NatsClient, NatsConfig}; use nvisy_postgres::{PgClient, PgClientMigrationExt, PgConfig}; use nvisy_webhook::WebhookService; +pub use crate::service::crypto::{CryptoConfig, CryptoService}; pub use crate::service::health::{HealthCache, HealthConfig}; pub use crate::service::security::{ - MasterKey, MasterKeyConfig, PasswordHasher, PasswordStrength, SessionKeys, SessionKeysConfig, - UserAgentParser, + PasswordHasher, PasswordStrength, SessionKeys, SessionKeysConfig, UserAgentParser, }; pub use crate::service::webhook::{WebhookEmitter, WebhookWorker}; use crate::{Error, Result}; @@ -34,7 +34,7 @@ pub struct ServiceState { pub webhook: WebhookService, // Security services: - pub master_key: MasterKey, + pub crypto: CryptoService, // Internal services: pub health_cache: HealthCache, @@ -53,14 +53,14 @@ impl ServiceState { postgres_config: PgConfig, nats_config: NatsConfig, session_config: SessionKeysConfig, - master_key_config: MasterKeyConfig, + crypto_config: CryptoConfig, health_config: HealthConfig, webhook_service: WebhookService, ) -> Result { let postgres_client = connect_postgres(postgres_config).await?; let nats_client = connect_nats(nats_config).await?; - let master_key = MasterKey::from_config(&master_key_config).await?; + let crypto = CryptoService::from_config(&crypto_config).await?; let session_keys = SessionKeys::from_config(&session_config).await?; let webhook_emitter = WebhookEmitter::new(postgres_client.clone(), nats_client.clone()); @@ -75,7 +75,7 @@ impl ServiceState { nats: nats_client, webhook: webhook_service, - master_key, + crypto, health_cache: HealthCache::new(&health_config, health_checkers), password_hasher: PasswordHasher::new(), @@ -128,7 +128,7 @@ impl_di!( // Internal services: impl_di!( - master_key: MasterKey, + crypto: CryptoService, health_cache: HealthCache, password_hasher: PasswordHasher, password_strength: PasswordStrength, diff --git a/crates/nvisy-server/src/service/security/master_key.rs b/crates/nvisy-server/src/service/security/master_key.rs deleted file mode 100644 index 0fef8cd..0000000 --- a/crates/nvisy-server/src/service/security/master_key.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! Master encryption key management for connection data. -//! -//! This module provides functionality for loading and managing the master encryption -//! key used to derive workspace-specific keys for encrypting connection credentials. -//! Workspace keys are derived via HKDF-SHA256 so the master key is never used directly. - -use std::fmt; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use uuid::Uuid; - -use crate::service::crypto::EncryptionKey; -use crate::{Error, Result}; - -/// Tracing target for master key operations. -const TRACING_TARGET: &str = "nvisy_server::master_key"; - -/// Master encryption key file path configuration. -#[derive(Debug, Clone)] -pub struct MasterKeyConfig { - /// File path to the 32-byte master encryption key. - pub key_path: PathBuf, -} - -impl Default for MasterKeyConfig { - fn default() -> Self { - Self { - key_path: "./encryption.key".into(), - } - } -} - -/// Master encryption key used to derive workspace-specific keys. -/// -/// This is a thin wrapper around [`EncryptionKey`] that adds file-based loading -/// and tracing. The underlying key is used exclusively to derive per-workspace -/// keys via HKDF-SHA256: it is never used for encryption directly. -#[derive(Clone)] -pub struct MasterKey { - inner: Arc, -} - -impl MasterKey { - /// Loads the master key from the path specified in `config`. - /// - /// The file must contain exactly 32 raw bytes (256-bit key). - pub async fn from_config(config: &MasterKeyConfig) -> Result { - Self::validate_path(&config.key_path)?; - Self::load(&config.key_path).await - } - - /// Loads the master key from a file path. - pub async fn new(key_path: impl AsRef) -> Result { - let path = key_path.as_ref(); - Self::validate_path(path)?; - Self::load(path).await - } - - /// Returns a reference to the underlying [`EncryptionKey`]. - #[inline] - pub fn encryption_key(&self) -> &EncryptionKey { - &self.inner - } - - /// Derives a workspace-specific encryption key via HKDF-SHA256. - #[inline] - #[must_use] - pub fn derive_workspace_key(&self, workspace_id: Uuid) -> EncryptionKey { - self.inner.derive_workspace_key(workspace_id) - } - - /// Validates that the key file exists and is a regular file. - fn validate_path(path: &Path) -> Result<()> { - if !path.exists() { - return Err(Error::config("Encryption key file does not exist")); - } - - if !path.is_file() { - return Err(Error::config("Encryption key path is not a file")); - } - - Ok(()) - } - - /// Reads and parses the 32-byte key from disk. - async fn load(path: &Path) -> Result { - tracing::debug!( - target: TRACING_TARGET, - path = %path.display(), - "Loading master encryption key", - ); - - let bytes = tokio::fs::read(path).await.map_err(|e| { - tracing::error!( - target: TRACING_TARGET, - path = %path.display(), - error = %e, - "Failed to read encryption key file", - ); - Error::file_system("Failed to read encryption key file").with_source(e) - })?; - - let key = EncryptionKey::from_bytes(&bytes).map_err(|e| { - tracing::error!( - target: TRACING_TARGET, - path = %path.display(), - error = %e, - "Invalid encryption key: expected exactly 32 bytes", - ); - Error::config("Invalid encryption key: expected exactly 32 bytes").with_source(e) - })?; - - tracing::info!( - target: TRACING_TARGET, - "Master encryption key loaded", - ); - - Ok(Self { - inner: Arc::new(key), - }) - } -} - -impl fmt::Debug for MasterKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MasterKey") - .field("key", &"[REDACTED]") - .finish() - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - - use super::*; - - #[tokio::test] - async fn load_valid_key() { - let temp_dir = TempDir::new().unwrap(); - let key_path = temp_dir.path().join("encryption.key"); - fs::write(&key_path, [0xABu8; 32]).unwrap(); - - let master_key = MasterKey::new(&key_path).await.unwrap(); - assert_eq!(master_key.encryption_key().as_bytes(), &[0xAB; 32]); - } - - #[tokio::test] - async fn reject_invalid_key_length() { - let temp_dir = TempDir::new().unwrap(); - let key_path = temp_dir.path().join("encryption.key"); - fs::write(&key_path, [0u8; 16]).unwrap(); - - assert!(MasterKey::new(&key_path).await.is_err()); - } - - #[tokio::test] - async fn reject_missing_file() { - let temp_dir = TempDir::new().unwrap(); - let key_path = temp_dir.path().join("nonexistent.key"); - - assert!(MasterKey::new(&key_path).await.is_err()); - } - - #[tokio::test] - async fn derive_workspace_key_delegates() { - let temp_dir = TempDir::new().unwrap(); - let key_path = temp_dir.path().join("encryption.key"); - let raw = [0x42u8; 32]; - fs::write(&key_path, raw).unwrap(); - - let master_key = MasterKey::new(&key_path).await.unwrap(); - let workspace_id = Uuid::new_v4(); - - let derived = master_key.derive_workspace_key(workspace_id); - let expected = EncryptionKey::from_bytes(&raw) - .unwrap() - .derive_workspace_key(workspace_id); - - assert_eq!(derived.as_bytes(), expected.as_bytes()); - } -} diff --git a/crates/nvisy-server/src/service/security/mod.rs b/crates/nvisy-server/src/service/security/mod.rs index 352abda..82db10c 100644 --- a/crates/nvisy-server/src/service/security/mod.rs +++ b/crates/nvisy-server/src/service/security/mod.rs @@ -3,13 +3,11 @@ //! This module provides authentication-related services including password hashing, //! JWT secret key management, password strength evaluation, and user agent parsing. -mod master_key; mod password_hasher; mod password_strength; mod session_keys; mod user_agent; -pub use master_key::{MasterKey, MasterKeyConfig}; pub use password_hasher::PasswordHasher; pub use password_strength::PasswordStrength; pub use session_keys::{SessionKeys, SessionKeysConfig};