Instantly share code, notes, and snippets.
Last active
November 19, 2020 23:17
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save tyjak/1bd94853d6958a897d7c3a1ed140ed90 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# GistID:1bd94853d6958a897d7c3a1ed140ed90 | |
import sys | |
import os | |
import os.path as path | |
import subprocess | |
import shutil | |
import argparse | |
import re | |
XCLIP = shutil.which('xclip') | |
XDOTOOL = shutil.which('xdotool') | |
DMENU = shutil.which('dmenu') | |
PASS = shutil.which('pass') | |
STORE = os.getenv('PASSWORD_STORE_DIR', | |
path.normpath(path.expanduser('~/.password-store'))) | |
XSEL_PRIMARY = "primary" | |
def get_xselection(selection): | |
if not selection: # empty or None | |
return None | |
for option in [XSEL_PRIMARY, "secondary", "clipboard"]: | |
if option[:len(selection)] == selection: | |
return option | |
return None | |
def check_output(args): | |
output = subprocess.check_output(args) | |
output = output.decode('utf-8').split('\n') | |
return output | |
def dmenu(choices, args=[], pass_generate=False, path=DMENU): | |
""" | |
Displays a menu with the given choices by executing dmenu | |
with the provided list of arguments. Returns the selected choice | |
or None if the menu was aborted. | |
""" | |
dmenu = subprocess.Popen([path] + args, | |
stdin=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
stdout=subprocess.PIPE) | |
choice_lines = '\n'.join(map(str, choices)) | |
choice, errors = dmenu.communicate(choice_lines.encode('utf-8')) | |
if dmenu.returncode not in [0, 1] \ | |
or (dmenu.returncode == 1 and len(errors) != 0): | |
print("'{} {}' returned {} and error:\n{}" | |
.format(path, ' '.join(args), dmenu.returncode, | |
errors.decode('utf-8')), | |
file=sys.stderr) | |
sys.exit(1) | |
choice = choice.decode('utf-8').rstrip() | |
return choice if choice in choices or pass_generate else None | |
def collect_choices(store, regex=None): | |
choices = [] | |
for dirpath, dirs, files in os.walk(store, followlinks=True): | |
dirsubpath = dirpath[len(store):].lstrip('/') | |
for f in files: | |
if f.endswith('.gpg'): | |
full_path = os.path.join(dirsubpath, f[:-4]) | |
if not regex or re.match(regex, full_path): | |
choices += [full_path] | |
return choices | |
def xdotool(entries, press_return, delay=None, window_id=None): | |
getwin = "" | |
always_opts = "--clearmodifiers" | |
if delay: | |
always_opts += " --delay '{}'".format(delay) | |
if not window_id: | |
getwin = "getactivewindow\n" | |
else: | |
always_opts += " --window {}".format(window_id) | |
commands = [c for e in entries[:-1] for c in ( | |
"type {} '{}'".format(always_opts, e), | |
"key {} Tab".format(always_opts))] | |
if len(entries) > 0: | |
commands += ["type {} '{}'".format(always_opts, entries[-1])] | |
if press_return: | |
commands += ["key {} Return".format(always_opts)] | |
for command in commands: | |
input_text = "{}{}".format(getwin, command) | |
subprocess.check_output([XDOTOOL, "-"], | |
input=input_text, | |
universal_newlines=True) | |
def get_pass_output(gpg_file, path=PASS, store=STORE, pass_generate=False): | |
environ = os.environ.copy() | |
environ["PASSWORD_STORE_DIR"] = store | |
passp = subprocess.Popen([path, gpg_file], env=environ, | |
stderr=subprocess.PIPE, | |
stdout=subprocess.PIPE) | |
output, err = passp.communicate() | |
if passp.returncode != 0: | |
if err.decode('utf-8').rstrip() == 'Error: {} is not in the password store.'.format(gpg_file): | |
output = generate_password(gpg_file, path=PASS, store=STORE) | |
else: | |
print("pass returned {} and error:\n{}".format( | |
passp.returncode, err.decode('utf-8')), file=sys.stderr) | |
sys.exit(1) | |
return output.decode('utf-8').split('\n') | |
def generate_password(new_pass_args, path=PASS, store=STORE): | |
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') | |
environ = os.environ.copy() | |
environ["PASSWORD_STORE_DIR"] = store | |
if '#' in new_pass_args: | |
password_name, password_login = new_pass_args.split('#') | |
else: | |
password_login = '' | |
password_name = new_pass_args | |
# generate password | |
passp = subprocess.Popen([path, 'generate', '--no-symbols', password_name], env=environ, | |
stderr=subprocess.PIPE, | |
stdout=subprocess.PIPE) | |
output, err = passp.communicate() | |
password = ansi_escape.sub('', output.decode('utf-8').rstrip().split('\n')[-1]) | |
# insert login | |
if password_login != '' and password: | |
passp = subprocess.Popen([path, 'insert', '-m', password_name], | |
env=environ, | |
stdin=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
stdout=subprocess.PIPE) | |
_input=bytes(str.encode("\n".join([password,'login: '+password_login]),'UTF-8')) | |
output, err = passp.communicate(input=_input) | |
return "\n".join([password,password_login]).encode('UTF-8') | |
def get_user_second_line(pass_output): | |
userline = pass_output[1].split() | |
user = None | |
if len(userline) > 1: | |
# assume the first 'word' after some prefix is the username | |
# TODO any better, reasonable assumption for lines | |
# with more 'words'? | |
user = userline[1] | |
elif len(userline) == 1: | |
# assume the user has no 'User: ' prefix or similar | |
user = userline[0] | |
return user | |
def get_user_by_pattern(pass_output, user_pattern): | |
for line in pass_output[1:]: | |
match = re.match(user_pattern, line) | |
if match and len(match.groups()) == 1: | |
return match.group(1) | |
return None | |
def get_user_pw(pass_output, user_pattern): | |
password = None | |
if len(pass_output) > 0: | |
password = pass_output[0] | |
user = None | |
if len(pass_output) > 1: | |
if user_pattern == '': | |
user = get_user_second_line(pass_output) | |
elif user_pattern is not None: | |
user = get_user_by_pattern(pass_output, user_pattern) | |
return user, password | |
def main(): | |
desc = ("A dmenu frontend to pass." | |
" All passed arguments not listed below, are passed to dmenu." | |
" If you need to pass arguments to dmenu which are in conflict" | |
" with the options below, place them after --." | |
" Requires xclip in default 'copy' mode.") | |
parser = argparse.ArgumentParser(description=desc) | |
parser.add_argument('-c', '--copy', dest='copy', action='store_true', | |
help=('Use xclip to copy the username and/or ' | |
'password into the primary/specified ' | |
'xselection(s). This is the default mode.')) | |
parser.add_argument('-t', '--type', dest='autotype', action='store_true', | |
help=('Use xdotool to type the username and/or ' | |
'password into the currently active window.')) | |
parser.add_argument('-r', '--return', dest='press_return', | |
action='store_true', | |
help='Presses "Return" after typing. Forces --type.') | |
parser.add_argument('-u', '--user', dest="get_user", nargs='?', const='', | |
default=None, | |
help='Copy/type the username, possibly search by given ' | |
'python regex pattern that must include a group (the ' | |
'user part). Example pattern: \'^user: (.*)\'') | |
parser.add_argument('-P', '--pw', dest="get_pass", action='store_true', | |
help=('Copy/type the password. Default, use -u -P to ' | |
'copy both username and password.')) | |
parser.add_argument('-s', '--store', dest="store", default=STORE, | |
help=('The path to the pass password store. ' | |
'Defaults to ~/.password-store')) | |
parser.add_argument('-d', '--delay', dest="xdo_delay", default=None, | |
help=('The delay between keystrokes. ' | |
'Defaults to xdotool\'s default.')) | |
parser.add_argument('-f', '--filter', dest="filter", default=None, | |
help='A regular expression to filter pass filenames.') | |
parser.add_argument('-B', '--pass', dest="pass_bin", default=PASS, | |
help=('The path to the pass binary. ' | |
'Cannot find a default path to pass, ' | |
'you must provide this option.' | |
if PASS is None else 'Defaults to ' + PASS)) | |
parser.add_argument('-D', '--dmenu', dest="dmenu_bin", default=DMENU, | |
help=('The path to the dmenu binary. ' | |
'Cannot find a default path to dmenu, ' | |
'you must provide this option.' | |
if DMENU is None else 'Defaults to ' + DMENU)) | |
parser.add_argument('-x', '--xsel', dest="xsel", default=XSEL_PRIMARY, | |
help=('The X selections into which to copy the ' | |
'username/password. Possible values are comma-' | |
'separated lists of prefixes of: ' | |
'primary, secondary, clipboard. E.g. -x p,s,c. ' | |
'Defaults to primary.')) | |
parser.add_argument('-e', '--execute', dest="execute", default=None, | |
help=('The path to a command to execute. The whole ' | |
'content of the decrypted gpg file from pass ' | |
'is provided to it on standard input. The full ' | |
'password name (within the store) is provided as ' | |
'first parameter. Arguments -s and -f are ' | |
'forwarded as parameters.' | |
'The command is executed in addition to and ' | |
'after specified -t, -c options are handled.')) | |
parser.add_argument('-g', '--generate', dest="pass_generate", action='store_true', | |
help=('Generate a password if not existing. To add ' | |
'a user enter the password name follow by a dash ' | |
'and by the username i.e. folder/pass#username.')) | |
split_args = [[]] | |
curr_args = split_args[0] | |
for arg in sys.argv[1:]: | |
if arg == "--": | |
split_args.append([]) | |
curr_args = split_args[-1] | |
continue | |
curr_args.append(arg) | |
args, unknown_args = parser.parse_known_args(args=split_args[0]) | |
if args.get_user is None and not args.get_pass: | |
args.get_pass = True | |
if args.press_return: | |
args.autotype = True | |
if not args.autotype and not args.execute: | |
args.copy = True | |
error = False | |
if args.pass_bin is None: | |
print("You need to provide a path to pass. See -h for more.", | |
file=sys.stderr) | |
error = True | |
if args.dmenu_bin is None: | |
print("You need to provide a path to dmenu. See -h for more.", | |
file=sys.stderr) | |
error = True | |
prompt = "" | |
if args.autotype: | |
if XDOTOOL is None: | |
print("You need to install xdotool.", file=sys.stderr) | |
error = True | |
if args.press_return: | |
prompt = "enter" | |
else: | |
prompt = "type" | |
if args.copy: | |
if XCLIP is None: | |
print("You need to install xclip.", file=sys.stderr) | |
error = True | |
prompt += ("," if prompt != "" else "") + "copy" | |
if args.execute: | |
if shutil.which(args.execute) is None: | |
print("The command to execute is not executable or does not exist.") | |
error = True | |
else: | |
prompt += (("," if prompt != "" else "") + | |
os.path.basename(args.execute)) | |
# make sure the password store exists | |
if not os.path.isdir(args.store): | |
print("The password store location, " + args.store + | |
", does not exist.", file=sys.stderr) | |
error = True | |
if shutil.which(args.pass_bin) is None: | |
print("The pass binary, {}, does not exist or is not executable." | |
.format(args.pass_bin), file=sys.stderr) | |
error = True | |
if error: | |
sys.exit(1) | |
dmenu_opts = ["-p", prompt] + unknown_args | |
# XXX for now, append all split off argument lists to dmenu's args | |
for arg_list in split_args[1:]: | |
dmenu_opts += arg_list | |
# get active window id now, it may change between dmenu/rofi and xdotool | |
window_id = None | |
if args.autotype: | |
window_id = check_output([XDOTOOL, 'getactivewindow'])[0] | |
choices = collect_choices(args.store, args.filter) | |
choice = dmenu(choices, dmenu_opts, args.pass_generate, args.dmenu_bin) | |
# Check if user aborted | |
if choice is None: | |
sys.exit(0) | |
pass_output = get_pass_output(choice, args.pass_bin, args.store, args.pass_generate) | |
user, pw = get_user_pw(pass_output, args.get_user) | |
info = [] | |
if user is not None: | |
info += [user] | |
if args.get_pass and pw is not None: | |
info += [pw] | |
clip = '\n'.join(info).encode('utf-8') | |
if args.autotype: | |
xdotool(info, args.press_return, args.xdo_delay, window_id) | |
if args.copy: | |
for selection in args.xsel.split(','): | |
xsel_arg = get_xselection(selection) | |
if xsel_arg: | |
xclip = subprocess.Popen([XCLIP, "-selection", xsel_arg], | |
stdin=subprocess.PIPE) | |
xclip.communicate(clip) | |
else: | |
print("Warning: Invalid xselection argument: {}." | |
.format(selection), file=sys.stderr) | |
if args.execute: | |
cmd_with_args=([args.execute, choice, "-s", args.store] + | |
(["-f", args.filter] if args.filter else [])) | |
cmd = subprocess.Popen(cmd_with_args, stdin=subprocess.PIPE, | |
stderr=subprocess.STDOUT, stdout=subprocess.PIPE) | |
output, _ = cmd.communicate('\n'.join(pass_output).encode('utf-8')) | |
if cmd.returncode != 0: | |
print("Command {} returned {} and output:\n{}".format( | |
args.execute, cmd.returncode, output.decode('utf-8')), | |
file=sys.stderr) | |
sys.exit(1) | |
else: | |
print(output.decode('utf-8'), end='') | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment