diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 142d02b0..d860bdd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,6 +189,34 @@ jobs: permissions: contents: write steps: + - name: Import GPG signing key + run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import + + - name: Sign release artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ needs.release-please.outputs.tag_name }}" + REPO="${{ github.repository }}" + mkdir -p artifacts sigs + + # Download all release assets (installers only, skip Tauri updater metadata) + gh release download "$TAG" --dir artifacts --repo "$REPO" \ + --pattern "*.deb" --pattern "*.rpm" --pattern "*.AppImage" \ + --pattern "*.dmg" --pattern "*.exe" --pattern "*.msi" \ + --pattern "*.nsis.zip" || true + + # Create detached signatures and upload + for file in artifacts/*; do + [ -f "$file" ] || continue + gpg --batch --yes --detach-sign --armor -o "sigs/$(basename "$file").sig" "$file" + done + + # Upload all .sig files to the release + if ls sigs/*.sig &>/dev/null; then + gh release upload "$TAG" sigs/*.sig --repo "$REPO" + fi + - name: Publish release env: GH_TOKEN: ${{ github.token }} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 5a788747..e0e5259f 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "McpMux", + "mainBinaryName": "mcpmux", "version": "0.1.0", "identifier": "com.mcpmux.desktop", "build": { @@ -87,17 +88,21 @@ "deb": { "section": "utils", "depends": [ - "libsecret-1-0", - "gnome-keyring", "librsvg2-2" ], + "recommends": [ + "libsecret-1-0", + "gnome-keyring" + ], "desktopTemplate": "linux/mcpmux.desktop" }, "rpm": { "depends": [ - "libsecret", - "gnome-keyring", "librsvg2" + ], + "recommends": [ + "libsecret", + "gnome-keyring" ] } } diff --git a/crates/mcpmux-storage/src/keychain_file.rs b/crates/mcpmux-storage/src/keychain_file.rs new file mode 100644 index 00000000..fa7bd282 --- /dev/null +++ b/crates/mcpmux-storage/src/keychain_file.rs @@ -0,0 +1,249 @@ +//! File-based key storage fallback for environments without an OS keychain. +//! +//! Used on Linux/macOS when Secret Service or Keychain is unavailable (headless servers, +//! WSL, minimal desktop environments). Keys are stored as raw bytes in files with +//! restrictive permissions (0600 on Unix), similar to how SSH protects `~/.ssh/` keys. +//! +//! This is less secure than OS keychain or DPAPI — any process running as the same user +//! can read the key files. For production deployments, install `gnome-keyring` or another +//! Secret Service provider. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use tracing::{debug, info}; +use zeroize::Zeroizing; + +use crate::crypto::{generate_master_key, KEY_SIZE}; +use crate::keychain::{generate_jwt_secret, JwtSecretProvider, MasterKeyProvider, JWT_SECRET_SIZE}; + +/// File name for the master encryption key. +const MASTER_KEY_FILE: &str = "master.key"; + +/// File name for the JWT signing secret. +const JWT_SECRET_FILE: &str = "jwt.key"; + +/// Set restrictive file permissions (owner read/write only). +fn set_owner_only_permissions(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to set permissions on {:?}", path))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +/// Write data to a file with restrictive permissions. +fn write_key_file(path: &Path, data: &[u8]) -> Result<()> { + fs::write(path, data).with_context(|| format!("Failed to write key file: {:?}", path))?; + set_owner_only_permissions(path)?; + Ok(()) +} + +/// File-based master key provider. +/// +/// Stores the master key as a raw byte file protected by filesystem permissions. +pub struct FileKeyProvider { + key_path: PathBuf, +} + +impl FileKeyProvider { + /// Create a new file key provider that stores keys in `/keys/`. + pub fn new(data_dir: &Path) -> Result { + let keys_dir = data_dir.join("keys"); + fs::create_dir_all(&keys_dir) + .with_context(|| format!("Failed to create keys directory: {:?}", keys_dir))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&keys_dir, fs::Permissions::from_mode(0o700))?; + } + + Ok(Self { + key_path: keys_dir.join(MASTER_KEY_FILE), + }) + } +} + +impl MasterKeyProvider for FileKeyProvider { + fn get_or_create_key(&self) -> Result> { + if self.key_path.exists() { + debug!("Reading master key from {:?}", self.key_path); + let data = fs::read(&self.key_path) + .with_context(|| format!("Failed to read key file: {:?}", self.key_path))?; + + if data.len() != KEY_SIZE { + anyhow::bail!( + "Invalid key size in file: expected {}, got {}", + KEY_SIZE, + data.len() + ); + } + + let mut key = Zeroizing::new([0u8; KEY_SIZE]); + key.copy_from_slice(&data); + debug!("Master key loaded from file"); + Ok(key) + } else { + info!("No master key found, generating new file-based key"); + let key = generate_master_key()?; + write_key_file(&self.key_path, &key)?; + info!("Master key generated and stored in {:?}", self.key_path); + Ok(Zeroizing::new(key)) + } + } + + fn key_exists(&self) -> bool { + self.key_path.exists() + } + + fn delete_key(&self) -> Result<()> { + if self.key_path.exists() { + fs::remove_file(&self.key_path) + .with_context(|| format!("Failed to delete key file: {:?}", self.key_path))?; + info!("Master key file deleted"); + } else { + debug!("No key file to delete"); + } + Ok(()) + } +} + +/// File-based JWT signing secret provider. +/// +/// Stores the JWT signing secret as a raw byte file protected by filesystem permissions. +pub struct FileJwtSecretProvider { + secret_path: PathBuf, +} + +impl FileJwtSecretProvider { + /// Create a new file JWT secret provider that stores secrets in `/keys/`. + pub fn new(data_dir: &Path) -> Result { + let keys_dir = data_dir.join("keys"); + fs::create_dir_all(&keys_dir) + .with_context(|| format!("Failed to create keys directory: {:?}", keys_dir))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&keys_dir, fs::Permissions::from_mode(0o700))?; + } + + Ok(Self { + secret_path: keys_dir.join(JWT_SECRET_FILE), + }) + } +} + +impl JwtSecretProvider for FileJwtSecretProvider { + fn get_or_create_secret(&self) -> Result> { + if self.secret_path.exists() { + debug!("Reading JWT secret from {:?}", self.secret_path); + let data = fs::read(&self.secret_path).with_context(|| { + format!("Failed to read JWT secret file: {:?}", self.secret_path) + })?; + + if data.len() != JWT_SECRET_SIZE { + anyhow::bail!( + "Invalid JWT secret size in file: expected {}, got {}", + JWT_SECRET_SIZE, + data.len() + ); + } + + let mut secret = Zeroizing::new([0u8; JWT_SECRET_SIZE]); + secret.copy_from_slice(&data); + debug!("JWT secret loaded from file"); + Ok(secret) + } else { + info!("No JWT secret found, generating new file-based secret"); + let secret = generate_jwt_secret()?; + write_key_file(&self.secret_path, &secret)?; + info!("JWT secret generated and stored in {:?}", self.secret_path); + Ok(Zeroizing::new(secret)) + } + } + + fn secret_exists(&self) -> bool { + self.secret_path.exists() + } + + fn delete_secret(&self) -> Result<()> { + if self.secret_path.exists() { + fs::remove_file(&self.secret_path).with_context(|| { + format!("Failed to delete JWT secret file: {:?}", self.secret_path) + })?; + info!("JWT secret file deleted"); + } else { + debug!("No JWT secret file to delete"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_master_key_provider() { + let tmp = tempfile::tempdir().unwrap(); + let provider = FileKeyProvider::new(tmp.path()).unwrap(); + + // Initially no key + assert!(!provider.key_exists()); + + // Get or create generates a key + let key1 = provider.get_or_create_key().unwrap(); + assert!(provider.key_exists()); + + // Getting again returns the same key + let key2 = provider.get_or_create_key().unwrap(); + assert_eq!(&*key1, &*key2); + + // Delete removes the key + provider.delete_key().unwrap(); + assert!(!provider.key_exists()); + + // New key is generated after delete + let key3 = provider.get_or_create_key().unwrap(); + assert_ne!(&*key1, &*key3); + } + + #[test] + fn test_file_jwt_secret_provider() { + let tmp = tempfile::tempdir().unwrap(); + let provider = FileJwtSecretProvider::new(tmp.path()).unwrap(); + + // Initially no secret + assert!(!provider.secret_exists()); + + // Get or create generates a secret + let secret1 = provider.get_or_create_secret().unwrap(); + assert!(provider.secret_exists()); + + // Getting again returns the same secret + let secret2 = provider.get_or_create_secret().unwrap(); + assert_eq!(&*secret1, &*secret2); + + // Delete removes the secret + provider.delete_secret().unwrap(); + assert!(!provider.secret_exists()); + } + + #[test] + fn test_file_key_is_correct_size() { + let tmp = tempfile::tempdir().unwrap(); + let provider = FileKeyProvider::new(tmp.path()).unwrap(); + + provider.get_or_create_key().unwrap(); + + let file_contents = fs::read(tmp.path().join("keys").join(MASTER_KEY_FILE)).unwrap(); + assert_eq!(file_contents.len(), KEY_SIZE); + } +} diff --git a/crates/mcpmux-storage/src/lib.rs b/crates/mcpmux-storage/src/lib.rs index 1c19037e..a976f08f 100644 --- a/crates/mcpmux-storage/src/lib.rs +++ b/crates/mcpmux-storage/src/lib.rs @@ -56,6 +56,8 @@ mod database; pub mod keychain; #[cfg(windows)] pub mod keychain_dpapi; +#[cfg(not(windows))] +pub mod keychain_file; mod repositories; pub use crypto::{generate_master_key, FieldEncryptor, KEY_SIZE}; @@ -66,6 +68,8 @@ pub use keychain::{ }; #[cfg(windows)] pub use keychain_dpapi::{DpapiJwtSecretProvider, DpapiKeyProvider}; +#[cfg(not(windows))] +pub use keychain_file::{FileJwtSecretProvider, FileKeyProvider}; pub use repositories::*; /// Default database file name. @@ -95,15 +99,27 @@ pub fn create_key_provider( #[cfg(not(windows))] { - let _ = data_dir; // suppress unused warning - Ok(Box::new(KeychainKeyProvider::new()?)) + // Try OS keychain first, fall back to file-based storage if unavailable + match KeychainKeyProvider::new() { + Ok(provider) => match provider.get_or_create_key() { + Ok(_) => return Ok(Box::new(provider)), + Err(e) => tracing::warn!( + "OS keychain unavailable ({e}), using file-based key storage. \ + For better security, install gnome-keyring or another Secret Service provider." + ), + }, + Err(e) => { + tracing::warn!("OS keychain unavailable ({e}), using file-based key storage.") + } + } + Ok(Box::new(FileKeyProvider::new(data_dir)?)) } } /// Create the platform-appropriate JWT secret provider. /// /// - **Windows**: Uses DPAPI file-based storage. -/// - **macOS/Linux**: Uses the OS keychain. +/// - **macOS/Linux**: Uses the OS keychain, with file-based fallback if unavailable. pub fn create_jwt_secret_provider( data_dir: &std::path::Path, ) -> anyhow::Result> { @@ -114,7 +130,17 @@ pub fn create_jwt_secret_provider( #[cfg(not(windows))] { - let _ = data_dir; - Ok(Box::new(KeychainJwtSecretProvider::new()?)) + match KeychainJwtSecretProvider::new() { + Ok(provider) => match provider.get_or_create_secret() { + Ok(_) => return Ok(Box::new(provider)), + Err(e) => tracing::warn!( + "OS keychain unavailable for JWT secret ({e}), using file-based storage." + ), + }, + Err(e) => tracing::warn!( + "OS keychain unavailable for JWT secret ({e}), using file-based storage." + ), + } + Ok(Box::new(FileJwtSecretProvider::new(data_dir)?)) } }