From e71cd88ff8c83e78cc08d4d60b56c6c563c4be21 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 29 Sep 2026 23:10:50 +0100 Subject: [PATCH] Add native portal lockdown policy and serialize configuration writes --- docs/portals.md | 7 +- packaging/portal/ferese-portals.conf | 1 + packaging/portal/ferese.portal | 2 +- portal/src/backend.rs | 11 +- portal/src/desktop.rs | 3 + portal/src/lockdown.rs | 262 +++++++++++++++++++++++++ portal/src/main.rs | 1 + scripts/tests/test_portal_contracts.py | 35 +++- 8 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 portal/src/lockdown.rs diff --git a/docs/portals.md b/docs/portals.md index 4fb5359..77166b8 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -24,6 +24,11 @@ standard file, print and application dialogs. Install both backends. current frontends manage those files themselves. Direct sessions launch standard XDG autostart entries after importing the display environment. User overrides, Hidden, OnlyShowIn, NotShowIn and TryExec are respected; previews skip autostart. +- **Lockdown:** standard printing, save-to-disk, application-handler, location, + camera, microphone and sound-output restrictions. All default to unrestricted. + Configure boolean `disable-*` fields under `portals { lockdown { ... } }`; + changes are signalled to the frontend. Invalid policy types retain the current + policy. Only the portal frontend may write backend policy properties. - **USB:** native confirmation showing device vendor/model and requested read-only or read/write access. The portal frontend handles device enumeration and opening. - **ScreenCast:** monitor sharing with a native picker and PipeWire streams. @@ -58,7 +63,7 @@ Matching D-Bus signatures does not establish complete feature parity. | RemoteDesktop | Missing authorized input injection and EIS transport | | Clipboard | Missing remote-session clipboard transfer | | InputCapture | Missing zones, pointer barriers and EIS transport | -| Lockdown | GTK availability depends on the installed build; no native policy provider | +| Lockdown | Native seven-property policy provider with persisted configuration and change signals | | Inhibit | GTK's GNOME-session/ScreenSaver integration does not provide complete Ferese session inhibition | The frontend supplies additional APIs such as OpenURI, network monitoring, diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf index 8fda0f6..00e588e 100644 --- a/packaging/portal/ferese-portals.conf +++ b/packaging/portal/ferese-portals.conf @@ -6,3 +6,4 @@ org.freedesktop.impl.portal.Screenshot=ferese; org.freedesktop.impl.portal.Wallpaper=ferese; org.freedesktop.impl.portal.Background=ferese; org.freedesktop.impl.portal.Usb=ferese; +org.freedesktop.impl.portal.Lockdown=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal index 5558d2c..c0c0114 100644 --- a/packaging/portal/ferese.portal +++ b/packaging/portal/ferese.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.ferese -Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb; +Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown; UseIn=Ferese; diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 0deb8ef..1674f67 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -26,6 +26,9 @@ use zbus::{ zvariant::{OwnedObjectPath, OwnedValue, Value}, }; pub(crate) type Options = HashMap; + +pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[derive(Debug)] enum StartError { Cancelled, @@ -154,7 +157,10 @@ pub(crate) async fn authorize( .sender() .ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?; let dbus = zbus::fdo::DBusProxy::new(connection).await?; - let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?; + let owner = dbus + .get_name_owner(FRONTEND.try_into().unwrap()) + .await + .map_err(|_| zbus::fdo::Error::AccessDenied("Desktop portal is unavailable".into()))?; if sender.as_str() != owner.as_str() { return Err(zbus::fdo::Error::AccessDenied( "Use the desktop portal".into(), @@ -661,6 +667,7 @@ pub async fn run() -> Result<(), Box> { let settings = crate::settings::Settings::new(); let requests = crate::desktop::Requests::default(); let background = crate::permissions::Background::new(requests.clone()); + let lockdown = crate::lockdown::Lockdown::new(); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? @@ -669,6 +676,7 @@ pub async fn run() -> Result<(), Box> { .serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))? .serve_at(PATH, crate::permissions::Usb(requests.clone()))? .serve_at(PATH, background.clone())? + .serve_at(PATH, lockdown.clone())? .serve_at( "/org/ferese/ScreenRecorder", RecorderControl(backend.clone()), @@ -677,6 +685,7 @@ pub async fn run() -> Result<(), Box> { .await?; tokio::spawn(settings.watch(connection.clone())); tokio::spawn(background.watch(connection.clone())); + tokio::spawn(lockdown.watch(connection.clone())); // Frontend death must revoke every stream, even if Session.Close never arrives. loop { tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index 79c7aa8..3325b2e 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -580,6 +580,9 @@ fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result Result<(), String> { + let _transaction = crate::backend::CONFIG_TRANSACTION + .lock() + .map_err(|_| "Configuration transaction failed")?; let path = ferese_config::config_path().ok_or("Missing config path")?; edit_wallpaper(&path, image, target) } diff --git a/portal/src/lockdown.rs b/portal/src/lockdown.rs new file mode 100644 index 0000000..684be17 --- /dev/null +++ b/portal/src/lockdown.rs @@ -0,0 +1,262 @@ +use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; +use tokio::sync::RwLock; +use zbus::{Connection, message::Header, object_server::SignalEmitter, zvariant::Value}; + +const PATH: &str = "/org/freedesktop/portal/desktop"; +const KEYS: [&str; 7] = [ + "disable-printing", + "disable-save-to-disk", + "disable-application-handlers", + "disable-location", + "disable-camera", + "disable-microphone", + "disable-sound-output", +]; + +#[derive(Clone)] +pub(crate) struct Lockdown(Arc>); + +impl Lockdown { + pub(crate) fn new() -> Self { + Self(Arc::new(RwLock::new(load().unwrap_or_default()))) + } + + pub(crate) async fn watch(self, connection: Connection) { + let emitter = SignalEmitter::new(&connection, PATH).unwrap(); + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let mut current = self.0.write().await; + let Some(next) = load() else { continue }; + if *current == next { + continue; + } + let changed = KEYS + .iter() + .enumerate() + .filter(|(index, _)| current[*index] != next[*index]) + .map(|(index, key)| (*key, Value::from(next[index]))) + .collect::>(); + *current = next; + drop(current); + let _ = zbus::fdo::Properties::properties_changed( + &emitter, + "org.freedesktop.impl.portal.Lockdown".try_into().unwrap(), + changed, + Cow::Borrowed(&[]), + ) + .await; + } + } + + async fn set( + &self, + index: usize, + value: bool, + connection: &Connection, + header: Option>, + ) -> zbus::fdo::Result<()> { + let header = + header.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing policy caller".into()))?; + crate::backend::authorize(connection, &header).await?; + let mut current = self.0.write().await; + store(index, value).map_err(zbus::fdo::Error::Failed)?; + current[index] = value; + Ok(()) + } +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Lockdown")] +impl Lockdown { + #[zbus(property, name = "disable-printing")] + async fn disable_printing(&self) -> bool { + self.0.read().await[0] + } + + #[zbus(property, name = "disable-printing")] + async fn set_disable_printing( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(0, value, connection, header).await + } + + #[zbus(property, name = "disable-save-to-disk")] + async fn disable_save_to_disk(&self) -> bool { + self.0.read().await[1] + } + + #[zbus(property, name = "disable-save-to-disk")] + async fn set_disable_save_to_disk( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(1, value, connection, header).await + } + + #[zbus(property, name = "disable-application-handlers")] + async fn disable_application_handlers(&self) -> bool { + self.0.read().await[2] + } + + #[zbus(property, name = "disable-application-handlers")] + async fn set_disable_application_handlers( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(2, value, connection, header).await + } + + #[zbus(property, name = "disable-location")] + async fn disable_location(&self) -> bool { + self.0.read().await[3] + } + + #[zbus(property, name = "disable-location")] + async fn set_disable_location( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(3, value, connection, header).await + } + + #[zbus(property, name = "disable-camera")] + async fn disable_camera(&self) -> bool { + self.0.read().await[4] + } + + #[zbus(property, name = "disable-camera")] + async fn set_disable_camera( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(4, value, connection, header).await + } + + #[zbus(property, name = "disable-microphone")] + async fn disable_microphone(&self) -> bool { + self.0.read().await[5] + } + + #[zbus(property, name = "disable-microphone")] + async fn set_disable_microphone( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(5, value, connection, header).await + } + + #[zbus(property, name = "disable-sound-output")] + async fn disable_sound_output(&self) -> bool { + self.0.read().await[6] + } + + #[zbus(property, name = "disable-sound-output")] + async fn set_disable_sound_output( + &self, + value: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Option>, + ) -> zbus::fdo::Result<()> { + self.set(6, value, connection, header).await + } +} + +fn values(document: &ferese_config::Document) -> Option<[bool; 7]> { + for path in ["portals", "portals.lockdown"] { + if document.get(path).is_some_and(|value| !value.is_object()) { + return None; + } + } + let mut values = [false; 7]; + for (index, key) in KEYS.iter().enumerate() { + if let Some(value) = document.get(&format!("portals.lockdown.{}", key.replace('-', "_"))) { + values[index] = value.as_bool()?; + } + } + Some(values) +} + +fn load() -> Option<[bool; 7]> { + let path = ferese_config::config_path()?; + let source = match std::fs::read_to_string(path) { + Ok(source) if source.len() <= 1024 * 1024 => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + _ => return None, + }; + values(&ferese_config::Document::parse(&source).ok()?) +} + +fn store(index: usize, value: bool) -> Result<(), String> { + let _transaction = crate::backend::CONFIG_TRANSACTION + .lock() + .map_err(|_| "Configuration transaction failed")?; + let path = ferese_config::config_path().ok_or("Missing config directory")?; + let source = match std::fs::read_to_string(&path) { + Ok(source) if source.len() <= 1024 * 1024 => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.to_string()), + _ => return Err("Configuration is too large".into()), + }; + let mut document = + ferese_config::Document::parse(&source).map_err(|error| error.to_string())?; + if values(&document).is_none() { + return Err("Invalid lockdown policy".into()); + } + document + .set( + &format!("portals.lockdown.{}", KEYS[index].replace('-', "_")), + value.into(), + ) + .map_err(|error| error.to_string())?; + let directory = path.parent().ok_or("Invalid config path")?; + std::fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?; + std::io::Write::write_all(&mut file, document.to_string().as_bytes()) + .map_err(|error| error.to_string())?; + file.as_file() + .sync_all() + .map_err(|error| error.to_string())?; + if std::fs::read_to_string(&path).unwrap_or_default() != source { + return Err("Configuration changed; please try again".into()); + } + file.persist(path).map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_policy_is_unrestricted_and_wrong_types_are_rejected() { + let empty = ferese_config::Document::parse("").unwrap(); + assert_eq!(values(&empty), Some([false; 7])); + let enabled = ferese_config::Document::parse( + "portals { lockdown { disable-camera #true; disable-save-to-disk #true; }; }", + ) + .unwrap(); + let values = values(&enabled).unwrap(); + assert!(values[1] && values[4]); + assert!(!values[0]); + let invalid = + ferese_config::Document::parse("portals { lockdown { disable-camera \"yes\"; }; }") + .unwrap(); + assert!(super::values(&invalid).is_none()); + for source in ["portals #false", "portals { lockdown \"bad\"; }"] { + let invalid = ferese_config::Document::parse(source).unwrap(); + assert!(super::values(&invalid).is_none()); + } + } +} diff --git a/portal/src/main.rs b/portal/src/main.rs index d7eaca0..e3e9f30 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -2,6 +2,7 @@ mod backend; mod capture; mod consent; mod desktop; +mod lockdown; mod parent; mod permissions; mod picker; diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index 9b94e5e..f1428b6 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -54,7 +54,7 @@ def private_bus_checks(): raise AssertionError((root / "backend.log").read_text()) time.sleep(0.05) node = ET.fromstring(xml) - for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb"): + for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown"): interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") assert interface is not None, name expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") @@ -65,6 +65,7 @@ def private_bus_checks(): assert actual is not None, (name, member.tag, member.attrib["name"]) if member.tag == "property": assert actual.attrib["type"] == member.attrib["type"] + assert actual.attrib["access"] == member.attrib["access"] else: def types(element, direction): return [arg.attrib["type"] for arg in element.findall("arg") @@ -89,6 +90,14 @@ def private_bus_checks(): raise AssertionError("Untrusted backend caller succeeded") except GLib.Error: pass + original_config = config.read_text() + try: + call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True)))) + raise AssertionError("Untrusted policy writer succeeded") + except GLib.Error as error: + assert "AccessDenied" in str(error), error + printing, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing")), "(v)") + assert printing is False and config.read_text() == original_config request_name = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] @@ -96,6 +105,30 @@ def private_bus_checks(): response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})") assert response == 2 and values == {} + call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True)))) + printing, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing")), "(v)") + assert printing is True + assert "disable-printing #true" in config.read_text() + call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", False)))) + + policy_signals = [] + subscription = bus.signal_subscribe(NAME, "org.freedesktop.DBus.Properties", "PropertiesChanged", PATH, + None, Gio.DBusSignalFlags.NONE, lambda *args: policy_signals.append(args[-1].unpack())) + valid_policy = config.read_text().replace("disable-printing #false", "disable-printing #true") + config.write_text(valid_policy) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not any(s[0] == "org.freedesktop.impl.portal.Lockdown" and s[1].get("disable-printing") is True for s in policy_signals): + GLib.MainContext.default().iteration(False) + time.sleep(0.02) + assert any(s[0] == "org.freedesktop.impl.portal.Lockdown" and s[1].get("disable-printing") is True for s in policy_signals), policy_signals + for invalid_policy in ('portals { lockdown { disable-printing "bad"; }; }', 'portals { lockdown "bad"; }', 'portals #false'): + config.write_text(original_config + "\n" + invalid_policy) + time.sleep(1.2) + printing, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing")), "(v)") + assert printing is True, invalid_policy + config.write_text(valid_policy) + bus.signal_unsubscribe(subscription) + usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""), (PATH + "/request/test/usb", "", "org.test.App", [], {})) response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})")