329 lines
13 KiB
Python
Executable File
329 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""Which applications the desktop portal has recorded an answer for.
|
|
|
|
Read from xdg-desktop-portal's permission store, which is where an application
|
|
that asks through the portal has its answer written down. That is the whole of
|
|
what this can control, and the limit is worth stating plainly rather than
|
|
implying a protection that does not exist: a native binary opens /dev/video0
|
|
directly and no desktop setting stands in its way. What this covers is Flatpaks
|
|
and anything else that goes through the portal -- which on this machine is most
|
|
of what would ever ask.
|
|
|
|
Six subjects, mapped onto the store's own layout rather than onto a tidier one:
|
|
|
|
camera, microphone entries of the store's `devices` table, keyed by the
|
|
device name. Plain yes/no, so they can be toggled.
|
|
background the `background` table's single `background` entry, also
|
|
plain yes/no. This is "may run when you closed it".
|
|
screencast its own table, and remote-desktop likewise -- but their
|
|
remote-desktop ids are opaque restore tokens, one per remembered
|
|
session, and the value beside each grant is a structured
|
|
GVariant describing which monitor or which input devices
|
|
were shared. Nothing here can rebuild one of those, so
|
|
these report which application holds grants and offer
|
|
only to drop them. There is deliberately no code path
|
|
that Sets them: a toggle that cannot be honoured is worse
|
|
than no toggle.
|
|
location listed only. geoclue is absent on this machine, so the
|
|
table is normally empty and the page omits the section
|
|
rather than showing an empty one.
|
|
|
|
A table nothing has asked for is reported as an empty list rather than omitted,
|
|
so the page can say "nothing has asked" instead of showing nothing at all.
|
|
|
|
panama-permissions snapshot
|
|
panama-permissions set TABLE APP_ID true|false
|
|
panama-permissions forget TABLE APP_ID
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
|
|
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
TABLE_ID = re.compile(r"^[a-z][a-z-]{0,31}$")
|
|
|
|
# The store's own ids for a remembered session are opaque tokens, so they are
|
|
# never accepted from a caller -- only discovered by listing the table.
|
|
ENTRY_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
|
|
ALLOWED = "yes"
|
|
DENIED = "no"
|
|
|
|
SIMPLE = "simple"
|
|
STRUCTURED = "structured"
|
|
READONLY = "readonly"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Table:
|
|
"""One subject on the page, and where the store keeps it.
|
|
|
|
`portal` is the store's table name, which is not always the subject's name:
|
|
camera and microphone both live in `devices`. `entries` names the store ids
|
|
to look under, or None to discover them by listing the table -- which is the
|
|
only option for screencast and remote-desktop, whose ids are restore tokens.
|
|
`writable` is the id a Set writes to, and is None for everything this
|
|
refuses to write.
|
|
"""
|
|
|
|
name: str
|
|
portal: str
|
|
entries: tuple[str, ...] | None
|
|
writable: str | None
|
|
kind: str
|
|
|
|
|
|
TABLES: tuple[Table, ...] = (
|
|
Table("camera", "devices", ("camera",), "camera", SIMPLE),
|
|
Table("microphone", "devices", ("microphone",), "microphone", SIMPLE),
|
|
Table("screencast", "screencast", None, None, STRUCTURED),
|
|
Table("remote-desktop", "remote-desktop", None, None, STRUCTURED),
|
|
Table("background", "background", None, "background", SIMPLE),
|
|
Table("location", "location", None, None, READONLY),
|
|
)
|
|
|
|
BY_NAME = {table.name: table for table in TABLES}
|
|
|
|
# Named once, here, so the refusal below reads as a list membership rather than
|
|
# as a chain of conditions someone could later add an exception to.
|
|
SIMPLE_TABLES = tuple(table.name for table in TABLES if table.kind == SIMPLE)
|
|
REVOKE_ONLY_TABLES = tuple(table.name for table in TABLES if table.kind == STRUCTURED)
|
|
|
|
|
|
class BoundaryError(RuntimeError):
|
|
"""A user-visible validation or permission-store failure."""
|
|
|
|
|
|
def run(command: list[str], timeout: float = 15.0) -> subprocess.CompletedProcess:
|
|
try:
|
|
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
|
except FileNotFoundError as error:
|
|
raise BoundaryError("busctl is not available.") from error
|
|
except subprocess.TimeoutExpired as error:
|
|
raise BoundaryError("The permission store did not respond.") from error
|
|
|
|
|
|
def portal_call(method: str, signature: str, *arguments: str) -> dict | None:
|
|
"""One call to the permission store, as JSON.
|
|
|
|
--json=short rather than busctl's text output, which escapes non-ASCII into
|
|
octal and would mangle an application name.
|
|
"""
|
|
result = run([
|
|
"busctl", "--user", "--json=short", "call",
|
|
"org.freedesktop.impl.portal.PermissionStore",
|
|
"/org/freedesktop/impl/portal/PermissionStore",
|
|
"org.freedesktop.impl.portal.PermissionStore",
|
|
method, signature, *arguments,
|
|
])
|
|
if result.returncode != 0:
|
|
detail = (result.stderr or "").strip()
|
|
# An entry nothing has ever asked for has no row at all, and the store
|
|
# says so as "No entry for camera". Matched on the store's actual words
|
|
# rather than a guess at them.
|
|
lowered = detail.lower()
|
|
if "no entry" in lowered or "not found" in lowered:
|
|
return None
|
|
raise BoundaryError(detail.splitlines()[-1] if detail else "The permission store refused that.")
|
|
try:
|
|
return json.loads(result.stdout or "null")
|
|
except json.JSONDecodeError as error:
|
|
raise BoundaryError("The permission store returned something unreadable.") from error
|
|
|
|
|
|
def available() -> bool:
|
|
result = run(["busctl", "--user", "list"])
|
|
return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "")
|
|
|
|
|
|
def entry_ids(table: Table) -> list[str]:
|
|
"""The store ids to look under for one subject.
|
|
|
|
Fixed for the device entries, discovered for everything else. A discovered
|
|
id that does not look like an id is dropped rather than passed back into the
|
|
store.
|
|
"""
|
|
if table.entries is not None:
|
|
return list(table.entries)
|
|
payload = portal_call("List", "s", table.portal)
|
|
if not payload:
|
|
return []
|
|
data = payload.get("data") or []
|
|
listed = data[0] if data and isinstance(data[0], list) else []
|
|
return [str(value) for value in listed if ENTRY_ID.match(str(value))]
|
|
|
|
|
|
def rows_for(table: Table) -> list[dict]:
|
|
"""One row per application, folded across every entry in the table.
|
|
|
|
screencast keeps a separate entry per remembered session, so an application
|
|
that has shared its screen four times appears four times in the store. The
|
|
page is answering "may this application share your screen", which is one
|
|
question, so the rows are folded by application and `grants` says how many
|
|
stored sessions are behind the row.
|
|
"""
|
|
folded: dict[str, dict] = {}
|
|
for entry in entry_ids(table):
|
|
payload = portal_call("Lookup", "ss", table.portal, entry)
|
|
if not payload:
|
|
continue
|
|
data = payload.get("data") or []
|
|
recorded = data[0] if data and isinstance(data[0], dict) else {}
|
|
if not isinstance(recorded, dict):
|
|
continue
|
|
for app_id, permissions in recorded.items():
|
|
values = [str(value) for value in (permissions or [])]
|
|
row = folded.setdefault(str(app_id), {
|
|
"app": str(app_id),
|
|
# Anything that is not an explicit "yes" is treated as withheld:
|
|
# guessing generously about a camera is the wrong way to be wrong.
|
|
"allowed": False,
|
|
"grants": 0,
|
|
"raw": "",
|
|
})
|
|
row["grants"] += 1
|
|
if ALLOWED in values:
|
|
row["allowed"] = True
|
|
if row["raw"] == "":
|
|
row["raw"] = ",".join(values)
|
|
|
|
rows = list(folded.values())
|
|
rows.sort(key=lambda row: row["app"].casefold())
|
|
return rows
|
|
|
|
|
|
def snapshot() -> dict:
|
|
if not available():
|
|
return {
|
|
"available": False,
|
|
"tables": {table.name: [] for table in TABLES},
|
|
"simpleTables": list(SIMPLE_TABLES),
|
|
"revokeOnlyTables": list(REVOKE_ONLY_TABLES),
|
|
"error": "The desktop portal's permission store is not running.",
|
|
}
|
|
|
|
tables: dict[str, list[dict]] = {}
|
|
problems: list[str] = []
|
|
for table in TABLES:
|
|
# One unreadable table must not take the other five down with it: a page
|
|
# that shows nothing because location is broken is worse than a page
|
|
# that shows five subjects and says location could not be read.
|
|
try:
|
|
tables[table.name] = rows_for(table)
|
|
except BoundaryError as error:
|
|
tables[table.name] = []
|
|
problems.append(f"{table.name}: {error}")
|
|
|
|
return {
|
|
"available": True,
|
|
"tables": tables,
|
|
"simpleTables": list(SIMPLE_TABLES),
|
|
"revokeOnlyTables": list(REVOKE_ONLY_TABLES),
|
|
"error": "; ".join(problems),
|
|
}
|
|
|
|
|
|
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
|
|
if not pattern.match(value or ""):
|
|
raise BoundaryError(message)
|
|
return value
|
|
|
|
|
|
def named(name: str) -> Table:
|
|
require(TABLE_ID, name, "That is not a permission table.")
|
|
table = BY_NAME.get(name)
|
|
if table is None:
|
|
raise BoundaryError("That is not a permission table this manages.")
|
|
return table
|
|
|
|
|
|
def set_permission(name: str, app: str, allowed: bool) -> None:
|
|
"""Record an answer. Only for the subjects whose value is a plain yes or no.
|
|
|
|
screencast and remote-desktop are refused here and have no other route to a
|
|
Set anywhere in this file. Their stored value is a structured description of
|
|
a session -- which monitor, which input devices -- and writing a bare "yes"
|
|
over it would leave the store holding a grant the portal cannot restore.
|
|
Dropping the grant is the honest operation, so that is the only one offered.
|
|
"""
|
|
table = named(name)
|
|
require(APP_ID, app, "That is not an application.")
|
|
if table.name not in SIMPLE_TABLES or table.writable is None:
|
|
if table.kind == STRUCTURED:
|
|
raise BoundaryError(
|
|
f"A {table.name} grant describes a whole session, so it can only "
|
|
"be revoked, not switched on and off.")
|
|
raise BoundaryError(f"{table.name} permissions are shown, not changed, here.")
|
|
# Permissions are an array of strings, so busctl needs the element count
|
|
# before the element -- "1 yes", not "yes".
|
|
portal_call("SetPermission", "sbssas", table.portal, "true", table.writable, app,
|
|
"1", ALLOWED if allowed else DENIED)
|
|
|
|
|
|
def forget(name: str, app: str) -> None:
|
|
"""Drop the recorded answer, so the application is asked again next time.
|
|
|
|
Every entry the application appears under, because one application can hold
|
|
several remembered screencast sessions and dropping one of them would leave
|
|
the row on the page looking unchanged.
|
|
"""
|
|
table = named(name)
|
|
require(APP_ID, app, "That is not an application.")
|
|
dropped = 0
|
|
for entry in entry_ids(table):
|
|
payload = portal_call("Lookup", "ss", table.portal, entry)
|
|
if not payload:
|
|
continue
|
|
data = payload.get("data") or []
|
|
recorded = data[0] if data and isinstance(data[0], dict) else {}
|
|
if not isinstance(recorded, dict) or app not in recorded:
|
|
continue
|
|
portal_call("DeletePermission", "sss", table.portal, entry, app)
|
|
dropped += 1
|
|
if dropped == 0:
|
|
raise BoundaryError("There was no recorded answer to forget.")
|
|
|
|
|
|
def main(arguments: list[str]) -> int:
|
|
try:
|
|
if arguments == ["snapshot"]:
|
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
|
return 0
|
|
|
|
if len(arguments) == 4 and arguments[0] == "set":
|
|
if arguments[3] not in ("true", "false"):
|
|
raise BoundaryError("That is not true or false.")
|
|
set_permission(arguments[1], arguments[2], arguments[3] == "true")
|
|
elif len(arguments) == 3 and arguments[0] == "forget":
|
|
forget(arguments[1], arguments[2])
|
|
else:
|
|
raise BoundaryError(
|
|
"Usage: panama-permissions snapshot | set TABLE APP true|false | "
|
|
"forget TABLE APP")
|
|
except BoundaryError as error:
|
|
try:
|
|
state = snapshot()
|
|
except BoundaryError:
|
|
state = {
|
|
"available": False,
|
|
"tables": {table.name: [] for table in TABLES},
|
|
"simpleTables": list(SIMPLE_TABLES),
|
|
"revokeOnlyTables": list(REVOKE_ONLY_TABLES),
|
|
}
|
|
state["error"] = str(error)
|
|
print(json.dumps(state, separators=(",", ":")))
|
|
return 0
|
|
|
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|