#!/usr/bin/env python3 # This file is a part of gnome-pass-search-provider. # # gnome-pass-search-provider is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # gnome-pass-search-provider is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with gnome-pass-search-provider. If not, see # . # Copyright (C) 2017 Jonathan Lestrelin # Author: Jonathan Lestrelin # This project was based on gnome-shell-search-github-repositories # Copyright (C) 2012 Red Hat, Inc. # Author: Ralph Bean # which itself was based on fedmsg-notify # Copyright (C) 2012 Red Hat, Inc. # Author: Luke Macken import dbus import dbus.service import re import subprocess from dbus.mainloop.glib import DBusGMainLoop from fuzzywuzzy import process, fuzz from gi.repository import GLib from os import getenv, walk from os.path import expanduser, join as path_join # Convenience shorthand for declaring dbus interface methods. # s.b.n. -> search_bus_name search_bus_name = "org.gnome.Shell.SearchProvider2" sbn = dict(dbus_interface=search_bus_name) class SearchPassService(dbus.service.Object): """ The pass search daemon. This service is started through DBus activation by calling the :meth:`Enable` method, and stopped with :meth:`Disable`. """ bus_name = "org.gnome.Pass.SearchProvider" _object_path = "/" + bus_name.replace(".", "/") def __init__(self): self.session_bus = dbus.SessionBus() bus_name = dbus.service.BusName(self.bus_name, bus=self.session_bus) dbus.service.Object.__init__(self, bus_name, self._object_path) self.password_store = getenv("PASSWORD_STORE_DIR") or expanduser( "~/.password-store" ) @dbus.service.method(in_signature="sasu", **sbn) def ActivateResult(self, id, terms, timestamp): self.send_password_to_clipboard(id) @dbus.service.method(in_signature="as", out_signature="as", **sbn) def GetInitialResultSet(self, terms): return self.get_result_set(terms) @dbus.service.method(in_signature="as", out_signature="aa{sv}", **sbn) def GetResultMetas(self, ids): return [ dict( id=id, name=id[1:] if id.startswith(":") else id, gicon="dialog-password", ) for id in ids ] @dbus.service.method(in_signature="asas", out_signature="as", **sbn) def GetSubsearchResultSet(self, previous_results, new_terms): return self.get_result_set(new_terms) @dbus.service.method(in_signature="asu", terms="as", timestamp="u", **sbn) def LaunchSearch(self, terms, timestamp): pass def get_result_set(self, terms): if terms[0] == "otp": field = terms[0] elif terms[0].startswith(":"): field = terms[0][1:] terms = terms[1:] else: field = None name = "".join(terms) password_list = [] for root, dirs, files in walk(self.password_store): dir_path = root[len(self.password_store) + 1 :] if dir_path.startswith("."): continue for filename in files: if filename[-4:] != ".gpg": continue path = path_join(dir_path, filename)[:-4] password_list.append(path) results = [ e[0] for e in process.extract( name, password_list, limit=5, scorer=fuzz.partial_ratio ) ] if field == "otp": results = [f"otp {r}" for r in results] elif field is not None: results = [f":{field} {r}" for r in results] return results def send_password_to_gpaste(self, base_args, name, field=None): try: gpaste = self.session_bus.get_object( "org.gnome.GPaste.Daemon", "/org/gnome/GPaste" ) output = subprocess.check_output( base_args + [name], stderr=subprocess.STDOUT, universal_newlines=True ) if field is not None: match = re.search( fr"^{field}:\s*(?P.+?)$", output, flags=re.I | re.M ) if match: password = match.group("value") else: raise RuntimeError( f"The field {field} was not found in " + "the pass file." ) else: password = output.split("\n", 1)[0] gpaste.AddPassword(name, password, dbus_interface="org.gnome.GPaste1") if "otp" in base_args: self.notify("Copied OTP password to clipboard:", body=f"{name}") elif field is not None: self.notify( f"Copied field {field} to clipboard:", body=f"{name}" ) else: self.notify("Copied password to clipboard:", body=f"{name}") except subprocess.CalledProcessError as e: self.notify("Failed to copy password!", body=e.output, error=True) except RuntimeError as e: self.notify("Failed to copy field!", body=e.output, error=True) def send_password_to_native_clipboard(self, base_args, name, field=None): if field is not None: self.notify( f"Cannot copy field values.", body="This feature requires GPaste.", error=True, ) return pass_cmd = subprocess.run(base_args + ["-c", name]) if pass_cmd.returncode: self.notify("Failed to copy password!", error=True) elif "otp" in base_args: self.notify("Copied OTP password to clipboard:", body=f"{name}") else: self.notify("Copied password to clipboard:", body=f"{name}") def send_password_to_clipboard(self, name): field = None if name.startswith("otp "): base_args = ["pass", "otp", "code"] name = name[4:] else: base_args = ["pass", "show"] if name.startswith(":"): field, name = name.split(" ", 1) field = field[1:] try: self.send_password_to_gpaste(base_args, name, field) except dbus.DBusException: # We couldn't join GPaste over D-Bus, # use pass native clipboard copy self.send_password_to_native_clipboard(base_args, name, field) def notify(self, message, body="", error=False): try: self.session_bus.get_object( "org.freedesktop.Notifications", "/org/freedesktop/Notifications" ).Notify( "Pass", 0, "dialog-password", message, body, "", {"transient": False if error else True}, 0 if error else 3000, dbus_interface="org.freedesktop.Notifications", ) except dbus.DBusException as err: print(f"Error {err} while trying to display {message}.") if __name__ == "__main__": DBusGMainLoop(set_as_default=True) SearchPassService() GLib.MainLoop().run()