Skip to content

Commit 9b60e0b

Browse files
authored
feat: file-based keychain fallback for headless Linux/WSL (#103)
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent acfcec2 commit 9b60e0b

4 files changed

Lines changed: 317 additions & 9 deletions

File tree

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,34 @@ jobs:
189189
permissions:
190190
contents: write
191191
steps:
192+
- name: Import GPG signing key
193+
run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import
194+
195+
- name: Sign release artifacts
196+
env:
197+
GH_TOKEN: ${{ github.token }}
198+
run: |
199+
TAG="${{ needs.release-please.outputs.tag_name }}"
200+
REPO="${{ github.repository }}"
201+
mkdir -p artifacts sigs
202+
203+
# Download all release assets (installers only, skip Tauri updater metadata)
204+
gh release download "$TAG" --dir artifacts --repo "$REPO" \
205+
--pattern "*.deb" --pattern "*.rpm" --pattern "*.AppImage" \
206+
--pattern "*.dmg" --pattern "*.exe" --pattern "*.msi" \
207+
--pattern "*.nsis.zip" || true
208+
209+
# Create detached signatures and upload
210+
for file in artifacts/*; do
211+
[ -f "$file" ] || continue
212+
gpg --batch --yes --detach-sign --armor -o "sigs/$(basename "$file").sig" "$file"
213+
done
214+
215+
# Upload all .sig files to the release
216+
if ls sigs/*.sig &>/dev/null; then
217+
gh release upload "$TAG" sigs/*.sig --repo "$REPO"
218+
fi
219+
192220
- name: Publish release
193221
env:
194222
GH_TOKEN: ${{ github.token }}

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "McpMux",
4+
"mainBinaryName": "mcpmux",
45
"version": "0.1.0",
56
"identifier": "com.mcpmux.desktop",
67
"build": {
@@ -87,17 +88,21 @@
8788
"deb": {
8889
"section": "utils",
8990
"depends": [
90-
"libsecret-1-0",
91-
"gnome-keyring",
9291
"librsvg2-2"
9392
],
93+
"recommends": [
94+
"libsecret-1-0",
95+
"gnome-keyring"
96+
],
9497
"desktopTemplate": "linux/mcpmux.desktop"
9598
},
9699
"rpm": {
97100
"depends": [
98-
"libsecret",
99-
"gnome-keyring",
100101
"librsvg2"
102+
],
103+
"recommends": [
104+
"libsecret",
105+
"gnome-keyring"
101106
]
102107
}
103108
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
//! File-based key storage fallback for environments without an OS keychain.
2+
//!
3+
//! Used on Linux/macOS when Secret Service or Keychain is unavailable (headless servers,
4+
//! WSL, minimal desktop environments). Keys are stored as raw bytes in files with
5+
//! restrictive permissions (0600 on Unix), similar to how SSH protects `~/.ssh/` keys.
6+
//!
7+
//! This is less secure than OS keychain or DPAPI — any process running as the same user
8+
//! can read the key files. For production deployments, install `gnome-keyring` or another
9+
//! Secret Service provider.
10+
11+
use std::fs;
12+
use std::path::{Path, PathBuf};
13+
14+
use anyhow::{Context, Result};
15+
use tracing::{debug, info};
16+
use zeroize::Zeroizing;
17+
18+
use crate::crypto::{generate_master_key, KEY_SIZE};
19+
use crate::keychain::{generate_jwt_secret, JwtSecretProvider, MasterKeyProvider, JWT_SECRET_SIZE};
20+
21+
/// File name for the master encryption key.
22+
const MASTER_KEY_FILE: &str = "master.key";
23+
24+
/// File name for the JWT signing secret.
25+
const JWT_SECRET_FILE: &str = "jwt.key";
26+
27+
/// Set restrictive file permissions (owner read/write only).
28+
fn set_owner_only_permissions(path: &Path) -> Result<()> {
29+
#[cfg(unix)]
30+
{
31+
use std::os::unix::fs::PermissionsExt;
32+
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
33+
.with_context(|| format!("Failed to set permissions on {:?}", path))?;
34+
}
35+
#[cfg(not(unix))]
36+
{
37+
let _ = path;
38+
}
39+
Ok(())
40+
}
41+
42+
/// Write data to a file with restrictive permissions.
43+
fn write_key_file(path: &Path, data: &[u8]) -> Result<()> {
44+
fs::write(path, data).with_context(|| format!("Failed to write key file: {:?}", path))?;
45+
set_owner_only_permissions(path)?;
46+
Ok(())
47+
}
48+
49+
/// File-based master key provider.
50+
///
51+
/// Stores the master key as a raw byte file protected by filesystem permissions.
52+
pub struct FileKeyProvider {
53+
key_path: PathBuf,
54+
}
55+
56+
impl FileKeyProvider {
57+
/// Create a new file key provider that stores keys in `<data_dir>/keys/`.
58+
pub fn new(data_dir: &Path) -> Result<Self> {
59+
let keys_dir = data_dir.join("keys");
60+
fs::create_dir_all(&keys_dir)
61+
.with_context(|| format!("Failed to create keys directory: {:?}", keys_dir))?;
62+
#[cfg(unix)]
63+
{
64+
use std::os::unix::fs::PermissionsExt;
65+
fs::set_permissions(&keys_dir, fs::Permissions::from_mode(0o700))?;
66+
}
67+
68+
Ok(Self {
69+
key_path: keys_dir.join(MASTER_KEY_FILE),
70+
})
71+
}
72+
}
73+
74+
impl MasterKeyProvider for FileKeyProvider {
75+
fn get_or_create_key(&self) -> Result<Zeroizing<[u8; KEY_SIZE]>> {
76+
if self.key_path.exists() {
77+
debug!("Reading master key from {:?}", self.key_path);
78+
let data = fs::read(&self.key_path)
79+
.with_context(|| format!("Failed to read key file: {:?}", self.key_path))?;
80+
81+
if data.len() != KEY_SIZE {
82+
anyhow::bail!(
83+
"Invalid key size in file: expected {}, got {}",
84+
KEY_SIZE,
85+
data.len()
86+
);
87+
}
88+
89+
let mut key = Zeroizing::new([0u8; KEY_SIZE]);
90+
key.copy_from_slice(&data);
91+
debug!("Master key loaded from file");
92+
Ok(key)
93+
} else {
94+
info!("No master key found, generating new file-based key");
95+
let key = generate_master_key()?;
96+
write_key_file(&self.key_path, &key)?;
97+
info!("Master key generated and stored in {:?}", self.key_path);
98+
Ok(Zeroizing::new(key))
99+
}
100+
}
101+
102+
fn key_exists(&self) -> bool {
103+
self.key_path.exists()
104+
}
105+
106+
fn delete_key(&self) -> Result<()> {
107+
if self.key_path.exists() {
108+
fs::remove_file(&self.key_path)
109+
.with_context(|| format!("Failed to delete key file: {:?}", self.key_path))?;
110+
info!("Master key file deleted");
111+
} else {
112+
debug!("No key file to delete");
113+
}
114+
Ok(())
115+
}
116+
}
117+
118+
/// File-based JWT signing secret provider.
119+
///
120+
/// Stores the JWT signing secret as a raw byte file protected by filesystem permissions.
121+
pub struct FileJwtSecretProvider {
122+
secret_path: PathBuf,
123+
}
124+
125+
impl FileJwtSecretProvider {
126+
/// Create a new file JWT secret provider that stores secrets in `<data_dir>/keys/`.
127+
pub fn new(data_dir: &Path) -> Result<Self> {
128+
let keys_dir = data_dir.join("keys");
129+
fs::create_dir_all(&keys_dir)
130+
.with_context(|| format!("Failed to create keys directory: {:?}", keys_dir))?;
131+
#[cfg(unix)]
132+
{
133+
use std::os::unix::fs::PermissionsExt;
134+
fs::set_permissions(&keys_dir, fs::Permissions::from_mode(0o700))?;
135+
}
136+
137+
Ok(Self {
138+
secret_path: keys_dir.join(JWT_SECRET_FILE),
139+
})
140+
}
141+
}
142+
143+
impl JwtSecretProvider for FileJwtSecretProvider {
144+
fn get_or_create_secret(&self) -> Result<Zeroizing<[u8; JWT_SECRET_SIZE]>> {
145+
if self.secret_path.exists() {
146+
debug!("Reading JWT secret from {:?}", self.secret_path);
147+
let data = fs::read(&self.secret_path).with_context(|| {
148+
format!("Failed to read JWT secret file: {:?}", self.secret_path)
149+
})?;
150+
151+
if data.len() != JWT_SECRET_SIZE {
152+
anyhow::bail!(
153+
"Invalid JWT secret size in file: expected {}, got {}",
154+
JWT_SECRET_SIZE,
155+
data.len()
156+
);
157+
}
158+
159+
let mut secret = Zeroizing::new([0u8; JWT_SECRET_SIZE]);
160+
secret.copy_from_slice(&data);
161+
debug!("JWT secret loaded from file");
162+
Ok(secret)
163+
} else {
164+
info!("No JWT secret found, generating new file-based secret");
165+
let secret = generate_jwt_secret()?;
166+
write_key_file(&self.secret_path, &secret)?;
167+
info!("JWT secret generated and stored in {:?}", self.secret_path);
168+
Ok(Zeroizing::new(secret))
169+
}
170+
}
171+
172+
fn secret_exists(&self) -> bool {
173+
self.secret_path.exists()
174+
}
175+
176+
fn delete_secret(&self) -> Result<()> {
177+
if self.secret_path.exists() {
178+
fs::remove_file(&self.secret_path).with_context(|| {
179+
format!("Failed to delete JWT secret file: {:?}", self.secret_path)
180+
})?;
181+
info!("JWT secret file deleted");
182+
} else {
183+
debug!("No JWT secret file to delete");
184+
}
185+
Ok(())
186+
}
187+
}
188+
189+
#[cfg(test)]
190+
mod tests {
191+
use super::*;
192+
193+
#[test]
194+
fn test_file_master_key_provider() {
195+
let tmp = tempfile::tempdir().unwrap();
196+
let provider = FileKeyProvider::new(tmp.path()).unwrap();
197+
198+
// Initially no key
199+
assert!(!provider.key_exists());
200+
201+
// Get or create generates a key
202+
let key1 = provider.get_or_create_key().unwrap();
203+
assert!(provider.key_exists());
204+
205+
// Getting again returns the same key
206+
let key2 = provider.get_or_create_key().unwrap();
207+
assert_eq!(&*key1, &*key2);
208+
209+
// Delete removes the key
210+
provider.delete_key().unwrap();
211+
assert!(!provider.key_exists());
212+
213+
// New key is generated after delete
214+
let key3 = provider.get_or_create_key().unwrap();
215+
assert_ne!(&*key1, &*key3);
216+
}
217+
218+
#[test]
219+
fn test_file_jwt_secret_provider() {
220+
let tmp = tempfile::tempdir().unwrap();
221+
let provider = FileJwtSecretProvider::new(tmp.path()).unwrap();
222+
223+
// Initially no secret
224+
assert!(!provider.secret_exists());
225+
226+
// Get or create generates a secret
227+
let secret1 = provider.get_or_create_secret().unwrap();
228+
assert!(provider.secret_exists());
229+
230+
// Getting again returns the same secret
231+
let secret2 = provider.get_or_create_secret().unwrap();
232+
assert_eq!(&*secret1, &*secret2);
233+
234+
// Delete removes the secret
235+
provider.delete_secret().unwrap();
236+
assert!(!provider.secret_exists());
237+
}
238+
239+
#[test]
240+
fn test_file_key_is_correct_size() {
241+
let tmp = tempfile::tempdir().unwrap();
242+
let provider = FileKeyProvider::new(tmp.path()).unwrap();
243+
244+
provider.get_or_create_key().unwrap();
245+
246+
let file_contents = fs::read(tmp.path().join("keys").join(MASTER_KEY_FILE)).unwrap();
247+
assert_eq!(file_contents.len(), KEY_SIZE);
248+
}
249+
}

crates/mcpmux-storage/src/lib.rs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ mod database;
5656
pub mod keychain;
5757
#[cfg(windows)]
5858
pub mod keychain_dpapi;
59+
#[cfg(not(windows))]
60+
pub mod keychain_file;
5961
mod repositories;
6062

6163
pub use crypto::{generate_master_key, FieldEncryptor, KEY_SIZE};
@@ -66,6 +68,8 @@ pub use keychain::{
6668
};
6769
#[cfg(windows)]
6870
pub use keychain_dpapi::{DpapiJwtSecretProvider, DpapiKeyProvider};
71+
#[cfg(not(windows))]
72+
pub use keychain_file::{FileJwtSecretProvider, FileKeyProvider};
6973
pub use repositories::*;
7074

7175
/// Default database file name.
@@ -95,15 +99,27 @@ pub fn create_key_provider(
9599

96100
#[cfg(not(windows))]
97101
{
98-
let _ = data_dir; // suppress unused warning
99-
Ok(Box::new(KeychainKeyProvider::new()?))
102+
// Try OS keychain first, fall back to file-based storage if unavailable
103+
match KeychainKeyProvider::new() {
104+
Ok(provider) => match provider.get_or_create_key() {
105+
Ok(_) => return Ok(Box::new(provider)),
106+
Err(e) => tracing::warn!(
107+
"OS keychain unavailable ({e}), using file-based key storage. \
108+
For better security, install gnome-keyring or another Secret Service provider."
109+
),
110+
},
111+
Err(e) => {
112+
tracing::warn!("OS keychain unavailable ({e}), using file-based key storage.")
113+
}
114+
}
115+
Ok(Box::new(FileKeyProvider::new(data_dir)?))
100116
}
101117
}
102118

103119
/// Create the platform-appropriate JWT secret provider.
104120
///
105121
/// - **Windows**: Uses DPAPI file-based storage.
106-
/// - **macOS/Linux**: Uses the OS keychain.
122+
/// - **macOS/Linux**: Uses the OS keychain, with file-based fallback if unavailable.
107123
pub fn create_jwt_secret_provider(
108124
data_dir: &std::path::Path,
109125
) -> anyhow::Result<Box<dyn JwtSecretProvider>> {
@@ -114,7 +130,17 @@ pub fn create_jwt_secret_provider(
114130

115131
#[cfg(not(windows))]
116132
{
117-
let _ = data_dir;
118-
Ok(Box::new(KeychainJwtSecretProvider::new()?))
133+
match KeychainJwtSecretProvider::new() {
134+
Ok(provider) => match provider.get_or_create_secret() {
135+
Ok(_) => return Ok(Box::new(provider)),
136+
Err(e) => tracing::warn!(
137+
"OS keychain unavailable for JWT secret ({e}), using file-based storage."
138+
),
139+
},
140+
Err(e) => tracing::warn!(
141+
"OS keychain unavailable for JWT secret ({e}), using file-based storage."
142+
),
143+
}
144+
Ok(Box::new(FileJwtSecretProvider::new(data_dir)?))
119145
}
120146
}

0 commit comments

Comments
 (0)