feat: protect profiles with explicit desktop wallet selection
This commit is contained in:
78
src/credentials/mod.rs
Normal file
78
src/credentials/mod.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use crate::{
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::{PrivateProfile, ProfileSelection},
|
||||||
|
};
|
||||||
|
use futures_util::future::BoxFuture;
|
||||||
|
pub mod preferences;
|
||||||
|
pub mod protection;
|
||||||
|
pub mod providers;
|
||||||
|
mod secret_service;
|
||||||
|
pub struct DesktopStore;
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum EnrollmentConsent {
|
||||||
|
Verified,
|
||||||
|
ProtectedByUser,
|
||||||
|
UnsafeBackendAccepted,
|
||||||
|
}
|
||||||
|
pub trait CredentialStore: Send + Sync {
|
||||||
|
fn load(&self, selection: ProfileSelection) -> BoxFuture<'static, Result<PrivateProfile>>;
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
selection: ProfileSelection,
|
||||||
|
profile: PrivateProfile,
|
||||||
|
consent: EnrollmentConsent,
|
||||||
|
) -> BoxFuture<'static, Result<()>>;
|
||||||
|
fn forget(&self, selection: ProfileSelection) -> BoxFuture<'static, Result<()>>;
|
||||||
|
}
|
||||||
|
impl CredentialStore for DesktopStore {
|
||||||
|
fn load(&self, s: ProfileSelection) -> BoxFuture<'static, Result<PrivateProfile>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
tokio::task::spawn_blocking(move || secret_service::load(s))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::new("WALLET_UNAVAILABLE", "Wallet operation did not complete.")
|
||||||
|
})?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
s: ProfileSelection,
|
||||||
|
mut p: PrivateProfile,
|
||||||
|
consent: EnrollmentConsent,
|
||||||
|
) -> BoxFuture<'static, Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let protection = protection::inspect_protection(&s.provider);
|
||||||
|
if protection == protection::ProtectionEvidence::Unprotected
|
||||||
|
&& !matches!(consent, EnrollmentConsent::UnsafeBackendAccepted)
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
"UNPROTECTED_WALLET",
|
||||||
|
"Protect the wallet in its native manager first.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if matches!(consent, EnrollmentConsent::Verified)
|
||||||
|
&& protection != protection::ProtectionEvidence::Protected
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
"UNVERIFIED_WALLET",
|
||||||
|
"Review wallet protection in setup.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
p.unsafe_storage = matches!(consent, EnrollmentConsent::UnsafeBackendAccepted);
|
||||||
|
secret_service::put(s, p)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::new("WALLET_UNAVAILABLE", "Wallet storage did not complete."))?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn forget(&self, s: ProfileSelection) -> BoxFuture<'static, Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
tokio::task::spawn_blocking(move || secret_service::forget(s))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::new("WALLET_UNAVAILABLE", "Wallet removal did not complete.")
|
||||||
|
})?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/credentials/preferences.rs
Normal file
119
src/credentials/preferences.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use crate::{
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::ProfileSelection,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::{Read, Write},
|
||||||
|
os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
pub struct PreferenceStore {
|
||||||
|
pub directory: PathBuf,
|
||||||
|
}
|
||||||
|
pub fn validate_profile(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 64
|
||||||
|
|| !name
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
|
{
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
impl PreferenceStore {
|
||||||
|
pub fn default_location() -> Result<Self> {
|
||||||
|
let root = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::new(
|
||||||
|
"CONFIG_UNAVAILABLE",
|
||||||
|
"Cannot locate your user configuration directory.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
directory: root.join("hermes-grocy-mcp"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn ensure_directory(&self) -> Result<()> {
|
||||||
|
if !self.directory.exists() {
|
||||||
|
fs::create_dir_all(&self.directory)
|
||||||
|
.map_err(|_| AppError::new("CONFIG_UNAVAILABLE", "Cannot create preferences."))?
|
||||||
|
}
|
||||||
|
let m = fs::symlink_metadata(&self.directory).map_err(|_| AppError::input())?;
|
||||||
|
if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"UNSAFE_PREFERENCES",
|
||||||
|
"Preferences must be in your owned directory.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700))
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn save(&self, s: &ProfileSelection) -> Result<()> {
|
||||||
|
validate_profile(&s.profile)?;
|
||||||
|
self.ensure_directory()?;
|
||||||
|
let tmp = self
|
||||||
|
.directory
|
||||||
|
.join(format!(".{}.{}.tmp", s.profile, std::process::id()));
|
||||||
|
let mut f = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW)
|
||||||
|
.open(&tmp)
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::new("CONFIG_UNAVAILABLE", "Cannot atomically write preferences.")
|
||||||
|
})?;
|
||||||
|
let result = (|| {
|
||||||
|
f.write_all(&serde_json::to_vec(s).map_err(|_| AppError::input())?)
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
f.sync_all().map_err(|_| AppError::input())?;
|
||||||
|
fs::rename(&tmp, self.directory.join(format!("{}.json", s.profile)))
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(tmp);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
pub fn load(&self, name: &str) -> Result<ProfileSelection> {
|
||||||
|
validate_profile(name)?;
|
||||||
|
let mut f = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||||
|
.open(self.directory.join(format!("{name}.json")))
|
||||||
|
.map_err(|_| AppError::new("PROFILE_NOT_FOUND", "Run setup to enroll this profile."))?;
|
||||||
|
let m = f.metadata().map_err(|_| AppError::input())?;
|
||||||
|
if !m.is_file()
|
||||||
|
|| m.uid() != unsafe { libc::geteuid() }
|
||||||
|
|| m.permissions().mode() & 0o077 != 0
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
"UNSAFE_PREFERENCES",
|
||||||
|
"Preferences must be owned by you and accessible only to you.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
Read::by_ref(&mut f)
|
||||||
|
.take(16385)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|_| AppError::input())?;
|
||||||
|
if bytes.len() > 16384 {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
let s: ProfileSelection = serde_json::from_slice(&bytes).map_err(|_| AppError::input())?;
|
||||||
|
if s.profile != name {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
pub fn remove(&self, name: &str) -> Result<()> {
|
||||||
|
self.load(name)?;
|
||||||
|
fs::remove_file(self.directory.join(format!("{name}.json"))).map_err(|_| AppError::input())
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/credentials/protection.rs
Normal file
66
src/credentials/protection.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use crate::model::ProviderIdentity;
|
||||||
|
use std::{
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::Read,
|
||||||
|
os::unix::fs::{MetadataExt, OpenOptionsExt},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProtectionEvidence {
|
||||||
|
Protected,
|
||||||
|
Unprotected,
|
||||||
|
Unverified,
|
||||||
|
}
|
||||||
|
pub fn inspect_header(path: &Path) -> ProtectionEvidence {
|
||||||
|
let Ok(mut f) = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
|
else {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
};
|
||||||
|
let Ok(meta) = f.metadata() else {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
};
|
||||||
|
if !meta.is_file() || meta.uid() != unsafe { libc::geteuid() } {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
}
|
||||||
|
let mut buf = [0u8; 20];
|
||||||
|
let Ok(n) = f.read(&mut buf) else {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
};
|
||||||
|
let h = &buf[..n];
|
||||||
|
if h.starts_with(b"GnomeKeyring\n\r\0\n\0\0\0\0") {
|
||||||
|
ProtectionEvidence::Protected
|
||||||
|
} else if h.starts_with(b"[keyring]") {
|
||||||
|
ProtectionEvidence::Unprotected
|
||||||
|
} else {
|
||||||
|
ProtectionEvidence::Unverified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn inspect_protection(identity: &ProviderIdentity) -> ProtectionEvidence {
|
||||||
|
// Only inspect a recognized provider's actual default collection label; no unrelated secret reads.
|
||||||
|
let Ok(owner) = super::providers::bind_selected(identity) else {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
};
|
||||||
|
let Ok(session) = super::secret_service::WalletSession::open(&owner) else {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
};
|
||||||
|
let label = session.collection.label();
|
||||||
|
if label.is_empty() || label.contains(['/', '\\']) || label == "." || label == ".." {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
}
|
||||||
|
let Some(home) = std::env::var_os("HOME") else {
|
||||||
|
return ProtectionEvidence::Unverified;
|
||||||
|
};
|
||||||
|
let root = std::env::var_os("XDG_DATA_HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(home).join(".local/share"));
|
||||||
|
let exe = identity.executable.to_string_lossy();
|
||||||
|
if exe.contains("gnome-keyring") {
|
||||||
|
inspect_header(&root.join("keyrings").join(format!("{label}.keyring")))
|
||||||
|
} else {
|
||||||
|
ProtectionEvidence::Unverified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use libsecret::prelude::*;
|
||||||
126
src/credentials/providers.rs
Normal file
126
src/credentials/providers.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use crate::{
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::ProviderIdentity,
|
||||||
|
};
|
||||||
|
use glib::variant::ToVariant;
|
||||||
|
use std::{collections::HashSet, path::PathBuf};
|
||||||
|
pub const ALIASES: &[(&str, &str)] = &[
|
||||||
|
("KDE KWallet", "org.kde.secretservicecompat"),
|
||||||
|
("KDE KWallet", "org.kde.kwalletd6"),
|
||||||
|
("KDE KWallet", "org.kde.kwalletd5"),
|
||||||
|
("GNOME Keyring", "org.gnome.keyring"),
|
||||||
|
("System Secret Service", "org.freedesktop.secrets"),
|
||||||
|
];
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProviderCandidate {
|
||||||
|
pub label: String,
|
||||||
|
pub identity: Option<ProviderIdentity>,
|
||||||
|
pub recommended: bool,
|
||||||
|
}
|
||||||
|
pub(crate) fn bus() -> Result<gio::DBusConnection> {
|
||||||
|
gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE).map_err(|_| {
|
||||||
|
AppError::new(
|
||||||
|
"WALLET_UNAVAILABLE",
|
||||||
|
"No desktop session bus. Run setup from your desktop session.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn call(
|
||||||
|
bus: &gio::DBusConnection,
|
||||||
|
method: &str,
|
||||||
|
params: Option<&glib::Variant>,
|
||||||
|
) -> Result<glib::Variant> {
|
||||||
|
bus.call_sync(
|
||||||
|
Some("org.freedesktop.DBus"),
|
||||||
|
"/org/freedesktop/DBus",
|
||||||
|
"org.freedesktop.DBus",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
None,
|
||||||
|
gio::DBusCallFlags::NO_AUTO_START,
|
||||||
|
5000,
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
)
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::new(
|
||||||
|
"WALLET_UNAVAILABLE",
|
||||||
|
"The selected wallet is not running. Open its native manager and retry.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn owner_identity(bus: &gio::DBusConnection, name: &str) -> Result<(String, PathBuf)> {
|
||||||
|
let owner = call(bus, "GetNameOwner", Some(&(name,).to_variant()))?
|
||||||
|
.get::<(String,)>()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.0;
|
||||||
|
let pid = call(
|
||||||
|
bus,
|
||||||
|
"GetConnectionUnixProcessID",
|
||||||
|
Some(&(owner.as_str(),).to_variant()),
|
||||||
|
)?
|
||||||
|
.get::<(u32,)>()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.0;
|
||||||
|
let exe = std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|_| {
|
||||||
|
AppError::new(
|
||||||
|
"WALLET_UNAVAILABLE",
|
||||||
|
"Cannot identify the selected wallet process.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((owner, exe))
|
||||||
|
}
|
||||||
|
pub(crate) fn bind_selected(identity: &ProviderIdentity) -> Result<String> {
|
||||||
|
let bus = bus()?;
|
||||||
|
let (owner, exe) = owner_identity(&bus, &identity.bus_name)?;
|
||||||
|
if exe != identity.executable {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"WALLET_PROVIDER_CHANGED",
|
||||||
|
"The wallet provider changed. Run setup to review its selection.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(owner)
|
||||||
|
}
|
||||||
|
pub fn discover_providers() -> Result<Vec<ProviderCandidate>> {
|
||||||
|
let bus = bus()?;
|
||||||
|
let names = call(&bus, "ListNames", None)?
|
||||||
|
.get::<(Vec<String>,)>()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.0;
|
||||||
|
let inactive = call(&bus, "ListActivatableNames", None)?
|
||||||
|
.get::<(Vec<String>,)>()
|
||||||
|
.ok_or_else(AppError::input)?
|
||||||
|
.0;
|
||||||
|
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for (label, name) in ALIASES {
|
||||||
|
if names.iter().any(|n| n == name) {
|
||||||
|
if let Ok((owner, exe)) = owner_identity(&bus, name) {
|
||||||
|
if seen.insert(owner) {
|
||||||
|
let recommended = (desktop.contains("kde")
|
||||||
|
&& exe.to_string_lossy().contains("kwallet"))
|
||||||
|
|| (desktop.contains("gnome")
|
||||||
|
&& exe.to_string_lossy().contains("gnome-keyring"));
|
||||||
|
output.push(ProviderCandidate {
|
||||||
|
label: label.to_string(),
|
||||||
|
identity: Some(ProviderIdentity {
|
||||||
|
bus_name: name.to_string(),
|
||||||
|
executable: exe,
|
||||||
|
}),
|
||||||
|
recommended,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if inactive.iter().any(|n| n == name) && !output.iter().any(|p| p.label == *label) {
|
||||||
|
output.push(ProviderCandidate {
|
||||||
|
label: format!("{label} (not running; open its manager)"),
|
||||||
|
identity: None,
|
||||||
|
recommended: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.sort_by_key(|p| (!p.recommended, p.identity.is_none()));
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
226
src/credentials/secret_service.rs
Normal file
226
src/credentials/secret_service.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
use crate::{
|
||||||
|
client::ApiBase,
|
||||||
|
error::{AppError, Result},
|
||||||
|
model::{AccessMode, PrivateProfile, ProfileSelection, TransportPolicy},
|
||||||
|
};
|
||||||
|
use gio::prelude::*;
|
||||||
|
use libsecret::prelude::*;
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{collections::HashMap, sync::mpsc, time::Duration};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
pub const APP_ID: &str = "io.github.hermes-grocy-mcp";
|
||||||
|
fn wallet_error() -> AppError {
|
||||||
|
AppError::new(
|
||||||
|
"WALLET_UNAVAILABLE",
|
||||||
|
"The selected wallet is unavailable or did not finish. Open its native manager and retry; no fallback was used.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub(crate) struct WalletSession {
|
||||||
|
pub collection: libsecret::Collection,
|
||||||
|
pub cancel: gio::Cancellable,
|
||||||
|
done: mpsc::Sender<()>,
|
||||||
|
}
|
||||||
|
impl WalletSession {
|
||||||
|
pub(crate) fn open(owner: &str) -> Result<Self> {
|
||||||
|
let cancel = gio::Cancellable::new();
|
||||||
|
let timer = cancel.clone();
|
||||||
|
let (done, rx) = mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if matches!(
|
||||||
|
rx.recv_timeout(Duration::from_secs(30)),
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout)
|
||||||
|
) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let service = libsecret::Service::open_sync(
|
||||||
|
libsecret::Service::static_type(),
|
||||||
|
Some(owner),
|
||||||
|
libsecret::ServiceFlags::OPEN_SESSION,
|
||||||
|
Some(&cancel),
|
||||||
|
)
|
||||||
|
.map_err(|_| wallet_error())?;
|
||||||
|
if service.session_algorithms().as_deref() != Some("dh-ietf1024-sha256-aes128-cbc-pkcs7") {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"UNENCRYPTED_SECRET_SESSION",
|
||||||
|
"This wallet cannot provide encrypted communication. Choose a compatible desktop wallet; plaintext communication is refused.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let collection = libsecret::Collection::for_alias_sync(
|
||||||
|
Some(&service),
|
||||||
|
"default",
|
||||||
|
libsecret::CollectionFlags::NONE,
|
||||||
|
Some(&cancel),
|
||||||
|
)
|
||||||
|
.map_err(|_| wallet_error())?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::new(
|
||||||
|
"NO_DEFAULT_WALLET",
|
||||||
|
"Create a protected default wallet using the native manager and retry.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if collection.is_locked() {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"WALLET_LOCKED",
|
||||||
|
"Unlock your default wallet in its native manager, then retry.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
collection,
|
||||||
|
cancel,
|
||||||
|
done,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn items(&self, name: &str) -> Result<Vec<libsecret::Item>> {
|
||||||
|
self.collection
|
||||||
|
.search_sync(
|
||||||
|
None,
|
||||||
|
attributes(name),
|
||||||
|
libsecret::SearchFlags::ALL,
|
||||||
|
Some(&self.cancel),
|
||||||
|
)
|
||||||
|
.map_err(|_| wallet_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for WalletSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.done.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn attributes(name: &str) -> HashMap<&str, &str> {
|
||||||
|
HashMap::from([("application", APP_ID), ("profile", name)])
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProfileRef<'a> {
|
||||||
|
version: u8,
|
||||||
|
api_base: &'a str,
|
||||||
|
api_key: &'a str,
|
||||||
|
access: AccessMode,
|
||||||
|
timezone: &'a Option<String>,
|
||||||
|
defaults: &'a Value,
|
||||||
|
ca_path: &'a Option<std::path::PathBuf>,
|
||||||
|
transport: TransportPolicy,
|
||||||
|
unsafe_storage: bool,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProfileWire {
|
||||||
|
version: u8,
|
||||||
|
api_base: String,
|
||||||
|
api_key: String,
|
||||||
|
access: AccessMode,
|
||||||
|
timezone: Option<String>,
|
||||||
|
defaults: Value,
|
||||||
|
ca_path: Option<std::path::PathBuf>,
|
||||||
|
transport: TransportPolicy,
|
||||||
|
unsafe_storage: bool,
|
||||||
|
}
|
||||||
|
fn encode(p: &PrivateProfile) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
|
serde_json::to_vec(&ProfileRef {
|
||||||
|
version: 1,
|
||||||
|
api_base: p.api_base.as_str(),
|
||||||
|
api_key: p.api_key.expose_secret(),
|
||||||
|
access: p.access,
|
||||||
|
timezone: &p.timezone,
|
||||||
|
defaults: &p.defaults,
|
||||||
|
ca_path: &p.ca_path,
|
||||||
|
transport: p.transport,
|
||||||
|
unsafe_storage: p.unsafe_storage,
|
||||||
|
})
|
||||||
|
.map(Zeroizing::new)
|
||||||
|
.map_err(|_| AppError::input())
|
||||||
|
}
|
||||||
|
fn decode(bytes: &[u8]) -> Result<PrivateProfile> {
|
||||||
|
if bytes.len() > 256 * 1024 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"INVALID_PROFILE",
|
||||||
|
"The wallet profile is invalid. Run setup again.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut w: ProfileWire = serde_json::from_slice(bytes).map_err(|_| {
|
||||||
|
AppError::new(
|
||||||
|
"INVALID_PROFILE",
|
||||||
|
"The wallet profile is invalid. Run setup again.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let key = SecretString::from(std::mem::take(&mut w.api_key));
|
||||||
|
if w.version != 1 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"INVALID_PROFILE",
|
||||||
|
"Run setup to refresh this profile.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(PrivateProfile {
|
||||||
|
api_base: ApiBase::parse(&w.api_base, w.transport)?,
|
||||||
|
api_key: key,
|
||||||
|
access: w.access,
|
||||||
|
timezone: w.timezone,
|
||||||
|
defaults: w.defaults,
|
||||||
|
ca_path: w.ca_path,
|
||||||
|
transport: w.transport,
|
||||||
|
unsafe_storage: w.unsafe_storage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn load(s: ProfileSelection) -> Result<PrivateProfile> {
|
||||||
|
super::preferences::validate_profile(&s.profile)?;
|
||||||
|
let owner = super::providers::bind_selected(&s.provider)?;
|
||||||
|
let session = WalletSession::open(&owner)?;
|
||||||
|
let items = session.items(&s.profile)?;
|
||||||
|
if items.len() != 1 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"PROFILE_NOT_FOUND",
|
||||||
|
"The wallet profile is missing or ambiguous. Run setup to review it.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let item = &items[0];
|
||||||
|
if item.is_locked() {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"WALLET_LOCKED",
|
||||||
|
"Unlock the wallet item in its native manager and retry.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
item.load_secret_sync(Some(&session.cancel))
|
||||||
|
.map_err(|_| wallet_error())?;
|
||||||
|
let secret = item.secret().ok_or_else(wallet_error)?;
|
||||||
|
let bytes = Zeroizing::new(secret.get());
|
||||||
|
decode(&bytes)
|
||||||
|
}
|
||||||
|
pub(crate) fn put(s: ProfileSelection, p: PrivateProfile) -> Result<()> {
|
||||||
|
super::preferences::validate_profile(&s.profile)?;
|
||||||
|
let owner = super::providers::bind_selected(&s.provider)?;
|
||||||
|
let session = WalletSession::open(&owner)?;
|
||||||
|
let bytes = encode(&p)?;
|
||||||
|
if bytes.len() > 256 * 1024 {
|
||||||
|
return Err(AppError::input());
|
||||||
|
}
|
||||||
|
let text = std::str::from_utf8(&bytes).map_err(|_| AppError::input())?;
|
||||||
|
let value = libsecret::Value::new(text, "application/json");
|
||||||
|
libsecret::Item::create_sync(
|
||||||
|
&session.collection,
|
||||||
|
None,
|
||||||
|
attributes(&s.profile),
|
||||||
|
&format!("Grocy MCP ({})", s.profile),
|
||||||
|
&value,
|
||||||
|
libsecret::ItemCreateFlags::REPLACE,
|
||||||
|
Some(&session.cancel),
|
||||||
|
)
|
||||||
|
.map_err(|_| wallet_error())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub(crate) fn forget(s: ProfileSelection) -> Result<()> {
|
||||||
|
super::preferences::validate_profile(&s.profile)?;
|
||||||
|
let owner = super::providers::bind_selected(&s.provider)?;
|
||||||
|
let session = WalletSession::open(&owner)?;
|
||||||
|
for item in session.items(&s.profile)? {
|
||||||
|
if item.is_locked() {
|
||||||
|
return Err(AppError::new(
|
||||||
|
"WALLET_LOCKED",
|
||||||
|
"Unlock the wallet item and retry.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
item.delete_sync(Some(&session.cancel))
|
||||||
|
.map_err(|_| wallet_error())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod credentials;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|||||||
138
tests/credentials.rs
Normal file
138
tests/credentials.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use hermes_grocy_mcp::{
|
||||||
|
credentials::{
|
||||||
|
CredentialStore, DesktopStore,
|
||||||
|
preferences::PreferenceStore,
|
||||||
|
protection::{ProtectionEvidence, inspect_header},
|
||||||
|
},
|
||||||
|
model::{ProfileSelection, ProviderIdentity},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
io::{BufRead, BufReader},
|
||||||
|
os::unix::fs::PermissionsExt,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
};
|
||||||
|
#[test]
|
||||||
|
fn protected_preferences() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let selection = ProfileSelection {
|
||||||
|
profile: "default".into(),
|
||||||
|
provider: ProviderIdentity {
|
||||||
|
bus_name: "org.freedesktop.secrets".into(),
|
||||||
|
executable: "/usr/bin/fixture-wallet".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let prefs = PreferenceStore {
|
||||||
|
directory: dir.path().join("prefs"),
|
||||||
|
};
|
||||||
|
prefs.save(&selection).unwrap();
|
||||||
|
assert_eq!(prefs.load("default").unwrap(), selection);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(prefs.directory.join("default.json"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
let header = dir.path().join("header");
|
||||||
|
std::fs::write(&header, b"[keyring]\nplaintext").unwrap();
|
||||||
|
assert_eq!(inspect_header(&header), ProtectionEvidence::Unprotected);
|
||||||
|
std::fs::write(&header, b"GnomeKeyring\n\r\0\n\0\0\0\0").unwrap();
|
||||||
|
assert_eq!(inspect_header(&header), ProtectionEvidence::Protected);
|
||||||
|
std::fs::write(&header, b"KWALLET\n\r\0\r\n").unwrap();
|
||||||
|
assert_eq!(inspect_header(&header), ProtectionEvidence::Unverified);
|
||||||
|
let link = dir.path().join("link");
|
||||||
|
std::os::unix::fs::symlink(&header, &link).unwrap();
|
||||||
|
assert_eq!(inspect_header(&link), ProtectionEvidence::Unverified);
|
||||||
|
assert!(prefs.load("../escape").is_err());
|
||||||
|
}
|
||||||
|
struct PlainService;
|
||||||
|
#[zbus::interface(name = "org.freedesktop.Secret.Service")]
|
||||||
|
impl PlainService {
|
||||||
|
fn open_session(
|
||||||
|
&self,
|
||||||
|
algorithm: &str,
|
||||||
|
_input: zbus::zvariant::OwnedValue,
|
||||||
|
) -> zbus::fdo::Result<(zbus::zvariant::OwnedValue, zbus::zvariant::OwnedObjectPath)> {
|
||||||
|
if algorithm != "plain" {
|
||||||
|
return Err(zbus::fdo::Error::NotSupported("Only plain".into()));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
zbus::zvariant::Value::from("").try_into().unwrap(),
|
||||||
|
"/org/freedesktop/secrets/session/plain".try_into().unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
#[zbus(property)]
|
||||||
|
fn collections(&self) -> Vec<zbus::zvariant::OwnedObjectPath> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn selected_wallet_only() {
|
||||||
|
if std::env::var_os("GROCY_WALLET_TEST_CHILD").is_some() {
|
||||||
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||||
|
let _conn = zbus::connection::Builder::address(
|
||||||
|
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap().as_str(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.name("org.freedesktop.secrets")
|
||||||
|
.unwrap()
|
||||||
|
.serve_at("/org/freedesktop/secrets", PlainService)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let selection = ProfileSelection {
|
||||||
|
profile: "default".into(),
|
||||||
|
provider: ProviderIdentity {
|
||||||
|
bus_name: "org.freedesktop.secrets".into(),
|
||||||
|
executable: std::fs::read_link("/proc/self/exe").unwrap(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
DesktopStore
|
||||||
|
.load(selection.clone())
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.code,
|
||||||
|
"UNENCRYPTED_SECRET_SESSION"
|
||||||
|
);
|
||||||
|
let mut wrong = selection;
|
||||||
|
wrong.provider.executable = "/not-the-selected-provider".into();
|
||||||
|
assert_eq!(
|
||||||
|
DesktopStore.load(wrong).await.err().unwrap().code,
|
||||||
|
"WALLET_PROVIDER_CHANGED"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut daemon = Command::new("dbus-daemon")
|
||||||
|
.args(["--session", "--nofork", "--print-address"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let mut address = String::new();
|
||||||
|
BufReader::new(daemon.stdout.take().unwrap())
|
||||||
|
.read_line(&mut address)
|
||||||
|
.unwrap();
|
||||||
|
let result = Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"selected_wallet_only",
|
||||||
|
"--nocapture",
|
||||||
|
"--test-threads=1",
|
||||||
|
])
|
||||||
|
.env("GROCY_WALLET_TEST_CHILD", "1")
|
||||||
|
.env("DBUS_SESSION_BUS_ADDRESS", address.trim())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let _ = daemon.kill();
|
||||||
|
let _ = daemon.wait();
|
||||||
|
assert!(
|
||||||
|
result.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&result.stdout)
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user