#!/usr/bin/env python2 # -*- coding: utf-8 -*- # YubiGuard VERSION 0.9.3 # LICENSE: GNU General Public License v3.0 # shell command for pushing ZMQ: # echo -e $(printf '\\x01\\x00\\x%02x\\x00%s' $((1 + ${#MSG})) "$MSG") # | nc -q1 $IP $PORT import argparse import os import re import subprocess import sys import time from multiprocessing import Process from multiprocessing.queues import Queue from threading import Thread import gi.repository import zmq gi.require_version('Gtk', '3.0') gi.require_version('AppIndicator3', '0.1') from gi.repository import AppIndicator3 as AppIndicator from gi.repository import Gtk # change working dir to that of script: abspath = os.path.abspath(__file__) d_name = os.path.dirname(abspath) os.chdir(d_name) # BASIC SETTINGS -------------------------------------------------------------- TIMEOUT = 5 APPINDICATOR_ID = 'yubiguard-indicator' HELP_URL = "https://github.com/bfelder/YubiGuard#usage" # icons: ICON_DIR = './icons/' OFF_ICON = ICON_DIR + 'off_icon.svg' ON_ICON = ICON_DIR + 'on_icon.svg' NOKEY_ICON = ICON_DIR + 'nokey_icon.svg' # Defining signals for queue communication: ON_SIGNAL = "ON" OFF_SIGNAL = 'OFF' NOKEY_SIGNAL = 'NOKEY' EXIT_SIGNAL = 'EXIT' URL = "tcp://{IP}:{PORT}".format(IP="127.0.0.1", PORT="5555") # static methods: def shell_this(cmd): p = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) stdout = [] while True: line = p.stdout.readline() stdout.append(line) # print line, if line == '' and p.poll() is not None: break return ''.join(stdout) def get_scrlck_cmd(): """ needs screensaver to run for the session: """ cmd_d = dict( cinammon='cinammon-screensaver-command -l', gnome='gnome-screensaver-command -l', mate='mate-screensaver-command -l', xfce='xflock4') sh_out = shell_this("ls /usr/bin/*session") for k, v in cmd_d.iteritems(): if k in sh_out: return v class PanelIndicator(object): def __init__(self, pi_q, on_q): self.indicator = AppIndicator.Indicator.new( APPINDICATOR_ID, os.path.abspath(NOKEY_ICON), AppIndicator.IndicatorCategory.SYSTEM_SERVICES) self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE) self.indicator.set_menu(self.build_menu) self.pi_q = pi_q self.on_q = on_q def run_pi(self): # suppresses error: Couldn't connect to accessibility bus: # Failed to connect to socket: shell_this("export NO_AT_BRIDGE=1") # listener loop for icon switch signals ui_thread = Thread(target=self.update_icon) ui_thread.daemon = True ui_thread.start() # starting Gtk main: Gtk.main() @property def build_menu(self): menu = Gtk.Menu() item_unlock = Gtk.MenuItem('Unlock') item_unlock.connect('activate', self.unlock) menu.append(item_unlock) self.item_unlock = item_unlock self.item_unlock.set_sensitive(False) # default state item_help = Gtk.MenuItem('Help') item_help.connect('activate', self.open_help) menu.append(item_help) separator = Gtk.SeparatorMenuItem() menu.append(separator) item_quit = Gtk.MenuItem('Quit') item_quit.connect('activate', self.quit) menu.append(item_quit) menu.show_all() return menu def unlock(self, *args): self.on_q.put(ON_SIGNAL) def open_help(self, *arg): help_cmd = "xdg-open {HELP_URL} || " \ "gnome-open {HELP_URL} || " \ "kde-open {HELP_URL} || " \ "notify-send --expire-time=7000" \ """ \"Could not open YubiGuard web help:\" \ \"{HELP_URL}\"""".format(HELP_URL=HELP_URL) shell_this(help_cmd) def quit(self, *arg): print('Quitting Gtk.') Gtk.main_quit() def update_icon(self): while True: if self.pi_q.qsize > 0: state = self.pi_q.get() if state == ON_SIGNAL: self.indicator.set_icon_full(os.path.abspath(ON_ICON), "") # activate unlock button self.item_unlock.set_sensitive(False) elif state == OFF_SIGNAL: self.indicator.set_icon_full(os.path.abspath(OFF_ICON), "") # deactivate unlock button self.item_unlock.set_sensitive(True) elif state == NOKEY_SIGNAL: self.indicator.set_icon_full( os.path.abspath(NOKEY_ICON), "") # deactivate unlock button self.item_unlock.set_sensitive(False) time.sleep(.1) class ZmqListener(object): def __init__(self, on_q): """ Listens for triggering through zmq message.""" self.on_q = on_q ctx = zmq.Context.instance() self.s = ctx.socket(zmq.PULL) self.s.bind(URL) def start_listener(self): print('ZMQ listener started') while True: try: self.s.recv(zmq.NOBLOCK) # note NOBLOCK here except zmq.Again: # no message to recv, do other things time.sleep(0.05) else: self.on_q.put(ON_SIGNAL) class AsynchronousFileReader(Thread): """ Helper class to implement asynchronous reading of a file in a separate thread. Pushes read lines on a queue to be consumed in another thread. Credits for this class goes to Stefaan Lippens: http://stefaanlippens.net/python-asynchronous-subprocess-pipe-reading """ def __init__(self, fd, queue): assert isinstance(queue, Queue) assert callable(fd.readline) Thread.__init__(self) self._fd = fd self._queue = queue def run(self): """ The body of the tread: read lines and put them on the queue.""" for line in iter(self._fd.readline, ''): self._queue.put(line) @property def eof(self): """ Check whether there is no more content to expect.""" return not self.is_alive() and self._queue.qsize() > 0 class YubiGuard: def __init__(self, scrlck_mode=False): self.scrlck_mode = scrlck_mode self.id_q = Queue() self.on_q = Queue() self.pi_q = Queue() # init processes gi_proc = Process(target=self.get_ids) gi_proc.daemon = True cs_proc = Process(target=self.change_state) # no daemon, or main program will terminate before Keys can be unlocked cs_proc.daemon = False zmq_lis = ZmqListener( self.on_q) # somehow works ony with threads not processes zmq_lis_thr = Thread(target=zmq_lis.start_listener) zmq_lis_thr.setDaemon(True) pi = PanelIndicator(self.pi_q, self.on_q) # starting processes and catching exceptions: try: gi_proc.start() cs_proc.start() zmq_lis_thr.start() pi.run_pi() # main loop of root process except (KeyboardInterrupt, SystemExit): print('Caught exit event.') finally: # send exit signal, will reactivate YubiKey slots print('Sending EXIT_SIGNAL') self.on_q.put(EXIT_SIGNAL) def get_ids(self): old_id_l = [] no_key = True pat = re.compile(r"(?:Yubikey.*?id=)(\d+)", re.IGNORECASE) while True: new_id_l = [] # get list of xinput device ids and extract those of YubiKeys: xinput = shell_this('xinput list') matches = re.findall(pat, xinput) new_id_l.extend(matches) new_id_l.sort() if not new_id_l and not no_key: self.pi_q.put(NOKEY_SIGNAL) print('No YubiKey(s) detected.') no_key = True elif new_id_l and no_key: self.pi_q.put(OFF_SIGNAL) print('YubiKey(s) detected.') no_key = False # notify: msg_cmd = """notify-send --expire-time=2000 \ 'YubiKey(s) detected.'""" shell_this(msg_cmd) if new_id_l != old_id_l: print('Change in YubiKey ids detected. From {} to {}.'.format( old_id_l, new_id_l)) self.id_q.put(new_id_l) # lock screen if screenlock and YubiKey is removed: if self.scrlck_mode and len(new_id_l) < len(old_id_l): print('Locking screen.') shell_this(get_scrlck_cmd()) # execute screen lock command old_id_l = new_id_l time.sleep(.1) def turn_keys(self, id_l, lock=True ): # problem of value loss of cs_id_l found in this function tk_id_l = id_l if lock: print('Locking YubiKey(s).') state_flag = '0' self.pi_q.put(OFF_SIGNAL) else: print('Unlocking YubiKey(s).') state_flag = '1' self.pi_q.put(ON_SIGNAL) shell_this('; '.join(["xinput set-int-prop {} \"Device Enabled\" 8 {}". format(tk_id, state_flag) for tk_id in tk_id_l])) def check_state(self, check_id_l): # check if all states have indeed changed: pat = re.compile(r"(?:Device Enabled.+?:).?([01])", re.IGNORECASE) # check if state has indeed changed: for tk_id in check_id_l: sh_out = shell_this('xinput list-props {}'.format(tk_id)) match = re.search(pat, sh_out) if match: if match.group(1) != '0': return False def change_state(self): cs_id_l = [] cs_signal = '' while True: # retrieve input from queues while self.id_q.qsize() > 0: cs_id_l = self.id_q.get() while self.on_q.qsize() > 0: cs_signal = self.on_q.get() # not accepting any more signals if cs_signal == EXIT_SIGNAL: self.turn_keys(cs_id_l, lock=False) sys.exit(0) # lock/unlock if cs_id_l: if cs_signal == ON_SIGNAL: self.turn_keys(cs_id_l, lock=False) mon_thread = Thread( target=self.yk_monitor, args=(cs_id_l, )) mon_thread.start() mon_thread.join() # putting in separator, nullifying all preceding ON_SIGNALS # to prevent possible over-triggering: self.on_q.put('') elif self.check_state( cs_id_l) is False: # lock keys if they are unlocked self.turn_keys(cs_id_l, lock=True) # reset state to prevent continued unlocking/locking cs_signal = '' time.sleep(.01) def yk_monitor(self, mon_l): # forming command to run parallel monitoring processes mon_cmd = ' & '.join(["xinput test {}".format(y_id) for y_id in mon_l]) monitor = subprocess.Popen(mon_cmd, shell=True, stdout=subprocess.PIPE) stdout_queue = Queue() stdout_reader = AsynchronousFileReader(monitor.stdout, stdout_queue) stdout_reader.start() triggered = False timestamp = time.time() while not stdout_reader.eof and time.time() - timestamp < TIMEOUT: while stdout_queue.qsize() > 0: stdout_queue.get() # emptying queue triggered = True time.sleep(.04) if triggered: print('YubiKey triggered. Now disabling.') break time.sleep(.001) if not triggered: print('No YubiKey triggered. Timeout.') # FIRING UP YUBIGUARD --------------------------------------------------------- if __name__ == "__main__": parser = argparse.ArgumentParser(description='YubiGuard help') parser.add_argument( '-t', nargs='?', default="", help='activates YubiGuard.py as trigger') parser.add_argument( '-l', nargs='?', default="", help='lock screen if any YubiKey is removed') args = parser.parse_args() if args.t is None: print("Sending ON_SIGNAL.") context = zmq.Context() zmq_socket = context.socket(zmq.PUSH) zmq_socket.connect(URL) zmq_socket.send(ON_SIGNAL) elif args.l is None: print("Starting YubiGuard in screen lock mode.") yg = YubiGuard(scrlck_mode=True) else: print("Starting YubiGuard.") yg = YubiGuard()