diff --git a/README.rst b/README.rst index ac2b4d8a3fea7b897558431576809fcc0042a8b5..f8fdb06c0228e621b7adb5ee106f3c6f24d712a5 100644 --- a/README.rst +++ b/README.rst @@ -7,8 +7,9 @@ cPasswords is a group password manager develop by the Client installation ------------------- -Please install ``python3 gettext python3-paramiko python3-pyperclip`` and -``xclip`` (optionnal). +Please install +``python3 gettext python3-paramiko python3-pyperclip python3-gpg`` +and ``xclip`` (optionnal). - Add you GPG fingerprint and your SSH key to the cpassword server. For the CRANS, you can do this on the intranet. diff --git a/cpasswords/client.py b/cpasswords/client.py index 7b2cf70b5155f58d01e17349478e25d7cf0920e6..785075d6b2c0e745262e795d7805f5e2603ff4a6 100755 --- a/cpasswords/client.py +++ b/cpasswords/client.py @@ -13,30 +13,23 @@ SPDX-License-Identifier: GPL-3.0-or-later # Import builtins import sys import subprocess -import json -import tempfile import os import argparse import re import copy import logging -import gettext +from tempfile import NamedTemporaryFile from configparser import ConfigParser from secrets import token_urlsafe -# Import setuptool, SSH client and clipboard -from pkg_resources import resource_filename -from paramiko.client import SSHClient -from paramiko.ssh_exception import SSHException +# Import thirdparty +from gpg.errors import KeyNotFound import pyperclip # Import modules -from .gpg import decrypt, encrypt, receive_keys, list_keys, GPG_TRUSTLEVELS - -# Load locale -gettext.bindtextdomain('messages', resource_filename("cpasswords", "locale")) -gettext.textdomain('messages') -_ = gettext.gettext +from .crypto import decrypt, encrypt, receive_key, get_key_from_fingerprint, check_key_validity +from .locale import _ +from .remote import remote_command # Configuration loading # On n'a pas encore accès à la config donc on devine le nom @@ -89,70 +82,6 @@ class SimpleMemoize(object): ###### # Remote commands -@SimpleMemoize -def create_ssh_client(host): - """ - Create a SSH client with paramiko module - """ - # Create SSH client with system host keys and agent - client = SSHClient() - client.load_system_host_keys() - try: - client.connect(host) - except SSHException: - log.error("An error occured during SSH connection, debug with -vv") - raise - - return client - - -def remote_command(options, command, arg=None, stdin_contents=None): - """ - Execute remote command and return output - """ - if "host" not in options.serverdata: - log.error("Missing parameter `host` in active server configuration") - exit(1) - client = create_ssh_client(str(options.serverdata['host'])) - - # Build command - if "remote_cmd" not in options.serverdata: - log.error("Missing parameter `remote_cmd` in active server configuration") - exit(1) - remote_cmd = options.serverdata['remote_cmd'] + " " + command - if arg: - remote_cmd += " " + arg - - # Run command and timeout after 10s - log.info("Running command `%s`" % remote_cmd) - stdin, stdout, stderr = client.exec_command(remote_cmd, timeout=10) - - # Write - if stdin_contents is not None: - log.info(_("Writing to stdin: %s") % stdin_contents) - stdin.write(json.dumps(stdin_contents)) - stdin.flush() - - # Return code == 0 if success - ret = stdout.channel.recv_exit_status() - if ret != 0: - err = "" - if stderr.channel.recv_stderr_ready(): - err = stderr.read() - log.error(_("Wrong server return code %s, error is %s") % (ret, err)) - exit(ret) - - # Decode directly read buffer - try: - answer = json.load(stdout) - except ValueError: - log.error(_("Error while parsing JSON")) - exit(42) - - log.debug("Server returned %s" % answer) - return answer - - @SimpleMemoize def all_keys(options): """Récupère les clés du serveur distant""" @@ -161,7 +90,9 @@ def all_keys(options): @SimpleMemoize def all_roles(options): - """Récupère les roles du serveur distant""" + """ + Fetch all roles and their users from server + """ return remote_command(options, "listroles") @@ -216,38 +147,6 @@ def gen_password(length=15): # Local commands -def update_keys(options): - """Met à jour les clés existantes""" - - keys = all_keys(options) - - _, stdout = receive_keys([key for _, key in keys.values() if key]) - return stdout.read().decode("utf-8") - - -def _check_encryptable(key): - """Vérifie qu'on peut chiffrer un message pour ``key``. - C'est-à -dire, que la clé est de confiance (et non expirée). - Puis qu'on peut chiffrer avec, ou qu'au moins une de ses subkeys est de chiffrement (capability e) - et est de confiance et n'est pas expirée""" - # Il faut que la clé soit dans le réseau de confiance… - meaning, trustvalue = GPG_TRUSTLEVELS[key["trustletter"]] - if not trustvalue: - return "La confiance en la clé est : %s" % (meaning,) - # …et qu'on puisse chiffrer avec… - if "e" in key["capabilities"]: - # …soit directement… - return "" - # …soit avec une de ses subkeys - esubkeys = [sub for sub in key["subkeys"] if "e" in sub["capabilities"]] - if len(esubkeys) == 0: - return "La clé principale de permet pas de chiffrer et auncune sous-clé de chiffrement." - if any([GPG_TRUSTLEVELS[sub["trustletter"]][1] for sub in esubkeys]): - return "" - else: - return "Aucune sous clé de chiffrement n'est de confiance et non expirée." - - def check_keys(options, recipients=None, quiet=False): """Vérifie les clés, c'est-à -dire, si le mail est présent dans les identités du fingerprint, et que la clé est de confiance (et non expirée/révoquée). @@ -259,47 +158,50 @@ def check_keys(options, recipients=None, quiet=False): * Si rien n'est fourni, vérifie toutes les clés et renvoie juste un booléen disant si tout va bien. """ trusted_recipients = [] + + # fetch all known keys from server keys = all_keys(options) + + # check only recipients if recipients is not None: - keys = {u: val for (u, val) in keys.iteritems() if u in recipients} - log.info("M : le mail correspond à un uid du fingerprint") - log.info("C : confiance OK (inclut la vérification de non expiration)") - localring = list_keys() - for (recipient, (mail, fpr)) in keys.iteritems(): - failed = "" - if fpr is not None: - log.info("Checking %s…" % mail) - key = localring.get(fpr, None) - # On vérifie qu'on possède la clé… - if key is not None: - # …qu'elle correspond au mail… - if any(["<%s>" % (mail,) in u["uid"] for u in key["uids"]]): - log.info("%s match fingerprint uid" % mail) - # … et qu'on peut raisonnablement chiffrer pour lui - failed = _check_encryptable(key) - if not failed: - log.info("%s is trusted and not expired" % mail) - else: - failed = "!! Le fingerprint et le mail ne correspondent pas !" - else: - failed = "Pas (ou trop) de clé avec ce fingerprint." - if failed: - log.warn("--> Fail on %s:%s\n--> %s" % (mail, fpr, failed)) - if recipients is not None: - # On cherche à savoir si on droppe ce recipient - message = "Abandonner le chiffrement pour cette clé ? (Si vous la conservez, il est posible que gpg crashe)" - if confirm(options, message, ('drop', fpr, mail)): - drop = True # si on a répondu oui à "abandonner ?", on droppe - elif options.drop_invalid and options.force: - drop = True # ou bien si --drop-invalid avec --force nous autorisent à dropper silencieusement - else: - drop = False # Là , on droppe pas - if not drop: - trusted_recipients.append(recipient) - else: - log.warn("Droppe la clé %s:%s" % (fpr, recipient)) + keys = {u: val for (u, val) in keys.items() if u in recipients} + + for (user, (email, fpr)) in keys.items(): + # fetch key + # TODO: use ignore-invalid option + try: + key = get_key_from_fingerprint(fpr) + except KeyNotFound: + log.error( + _("key correponding to fingerprint %s and mail %s not found") % (fpr, email)) + + # ask to download key + res = confirm(options, _("Do you want to try to import it now ?")) + if res: + receive_key(fpr) + try: + key = get_key_from_fingerprint(fpr) + except KeyNotFound: + # this time ignore + log.warn(_("Dropping key from %s") % email) + continue else: - trusted_recipients.append(recipient) + # ignore + log.warn(_("Dropping key from %s") % email) + continue + + # check that we should encrypt for this key + should_trust = check_key_validity(key, email) + if should_trust: + log.info("Trusting %s" % email) + else: + log.warn("Not trusting %s" % email) + message = "Abandonner le chiffrement pour cette clé ? (Si vous la conservez, il est possible que gpg crashe)" + if confirm(options, message, ('drop', fpr, email)): + # ignore key + continue + + trusted_recipients.append(user) if recipients is None: return set(keys.keys()).issubset(trusted_recipients) else: @@ -331,14 +233,13 @@ def my_encrypt(options, roles, contents): allkeys = all_keys(options) recipients = get_recipients_of_roles(options, roles) recipients = check_keys(options, recipients=recipients, quiet=True) - fpr_recipients = [] + keys = [] for recipient in recipients: fpr = allkeys[recipient][1] if fpr: - fpr_recipients.append("-r") - fpr_recipients.append(fpr) + keys.append(get_key_from_fingerprint(fpr)) - out = encrypt(contents, fpr_recipients) + out = encrypt(contents, keys) if out == '': return False, "Échec de chiffrement" @@ -370,35 +271,39 @@ def need_filename(f): return f -def editor(texte, annotations=""): +def editor(text, annotations=""): """ Lance $EDITOR sur texte. Renvoie le nouveau texte si des modifications ont été apportées, ou None """ - - # Avoid syntax hilight with ".txt". Would be nice to have some colorscheme - # for annotations ... - f = tempfile.NamedTemporaryFile(suffix='.txt') if annotations: annotations = "# " + annotations.replace("\n", "\n# ") # Usually, there is already an ending newline in a text document - if texte and texte[-1] != '\n': + if text and text[-1] != '\n': annotations = '\n' + annotations else: annotations += '\n' - f.write((texte + annotations).encode("utf-8")) - f.flush() - proc = subprocess.Popen([os.getenv('EDITOR', '/usr/bin/editor'), f.name]) - os.waitpid(proc.pid, 0) - f.seek(0) - ntexte = f.read().decode("utf-8", errors='ignore') - f.close() - ntexte = '\n'.join( - filter(lambda l: not l.startswith('#'), ntexte.split('\n'))) - return ntexte + text += annotations + + # use tempory file with .yml to get syntax coloration + new_text = "" + with NamedTemporaryFile(suffix='.yml') as f: + f.write(text.encode("utf-8")) + f.flush() + subprocess.run( + [os.getenv('EDITOR', '/usr/bin/editor'), f.name], check=True) + f.seek(0) + for line in f.readlines(): + # remove comment lines + if not line.startswith(b'#'): + new_text += line.decode("utf-8", errors='ignore') + + return new_text def show_files(options): - """Affiche la liste des fichiers disponibles sur le serveur distant""" + """ + Action to list files by role + """ my_roles, my_roles_w = get_my_roles(options) files = all_files(options) keys = list(files.keys()) @@ -412,7 +317,9 @@ def show_files(options): def restore_files(options): - """Restore les fichiers corrompues sur le serveur distant""" + """ + Action to restore corrumpted files + """ print(_("Fichier corrompus :")) files = restore_all_files(options) keys = files.keys() @@ -422,21 +329,27 @@ def restore_files(options): def show_roles(options): - """Affiche la liste des roles existants""" - print(_("Liste des roles disponibles")) + """ + Action to list roles and their users + """ + print(_("Listing available roles and their users")) allroles = all_roles(options) - for (role, usernames) in allroles.iteritems(): + for role, usernames in allroles.items(): if role == "whoami": continue if not role.endswith('-w'): - print(" * %s : %s" % (role, ", ".join(usernames))) + print("-> %s : %s" % (role, ", ".join(usernames))) -def show_servers(options): - """Affiche la liste des serveurs disponibles""" - print(_("Liste des serveurs disponibles")) - for server in config.keys(): - print(" * " + server) +def list_servers(options): + """ + Action to list all configured servers + """ + print(_("List of configured servers")) + for name, param in config.items(): + host = param.get("host", _("undefined")) + remote_cmd = param.get("remote_cmd", _("undefined")) + print(f"-> {name:8}\t{host:16}\t{remote_cmd}") def saveclipboard(restore=False, old_clipboard=None): @@ -453,7 +366,9 @@ def saveclipboard(restore=False, old_clipboard=None): @need_filename def show_file(options): - """Affiche le contenu d'un fichier""" + """ + Action that decrypt file content + """ fname = options.filename gotit, value = get_file(options, fname) if not gotit: @@ -501,7 +416,7 @@ def show_file(options): fname, filtered, ','.join(passfile['roles'])) if is_key: - with tempfile.NamedTemporaryFile(suffix='') as key_file: + with NamedTemporaryFile(suffix='') as key_file: # Génère la clé publique correspondante key_file.write(texte.encode('utf-8')) key_file.flush() @@ -513,7 +428,7 @@ def show_file(options): # On attend (hors tmpfile) print(shown) input() - with tempfile.NamedTemporaryFile(suffix='') as pub_file: + with NamedTemporaryFile(suffix='') as pub_file: # On met la clé publique en fichier pour suppression pub_file.write(pub) pub_file.flush() @@ -535,33 +450,36 @@ def show_file(options): @need_filename def edit_file(options): - """Modifie/Crée un fichier""" - fname = options.filename - gotit, value = get_file(options, fname) - nfile = False - annotations = "" + """ + Action to decrypt, edit a file and encrypt + """ + # fetch ciphertext from server + file_exist, value = get_file(options, options.filename) + # fetch my roles from server my_roles, _ = get_my_roles(options) + + nfile = False + comments = "" new_roles = options.roles # Cas du nouveau fichier - if not gotit and "pas les droits" not in value: + if not file_exist and "pas les droits" not in value: nfile = True - if not options.quiet: - print("Fichier introuvable") + log.info(_("File not found on server")) if not confirm(options, "Créer fichier ?"): - return - annotations += """Ceci est un fichier initial contenant un mot de passe -aléatoire, pensez à rajouter une ligne "login: ${login}" -Enregistrez le fichier vide pour annuler.\n""" + exit(1) + comments += ("Ceci est un fichier initial contenant un mot de passe\n" + "aléatoire, pensez à rajouter une ligne `login: ${login}`\n" + "Enregistrez le fichier vide pour annuler.\n") texte = "pass: %s\n" % gen_password() if new_roles is None: new_roles = parse_roles(options, cast=True) passfile = {'roles': new_roles} - elif not gotit: + elif not file_exist: log.warn(value) - return + exit(1) else: passfile = value contents = passfile['contents'] @@ -588,14 +506,14 @@ Enregistrez le fichier vide pour annuler.\n""" if not confirm(options, message): return - annotations += """Ce fichier sera chiffré pour les rôles suivants :\n%s\n -C'est-à -dire pour les utilisateurs suivants :\n%s""" % ( - ', '.join(new_roles), - '\n'.join(' %s' % - rec for rec in get_dest_of_roles(options, new_roles)) - ) + comments += ("Ce fichier sera chiffré pour les rôles suivants :\n%s\n" + "C'est-à -dire pour les utilisateurs suivants :\n%s""" % ( + ', '.join(new_roles), + '\n'.join(' %s' % + rec for rec in get_dest_of_roles(options, new_roles)) + )) - ntexte = editor(texte, annotations) + ntexte = editor(texte, comments) if ((not nfile and ntexte in ['', texte] # pas nouveau, vidé ou pas modifié and set(new_roles) == set(passfile['roles'])) # et on n'a même pas touché à ses rôles, @@ -640,7 +558,9 @@ def confirm(options, text, remember_key=None): @need_filename def remove_file(options): - """Supprime un fichier""" + """ + Action to delete a file + """ fname = options.filename if not confirm(options, 'Êtes-vous sûr de vouloir supprimer %s ?' % (fname,)): return @@ -649,14 +569,23 @@ def remove_file(options): def my_check_keys(options): - """Vérifie les clés et affiche un message en fonction du résultat""" + """ + Action to check all keys + """ print(_("Vérification que les clés sont valides (uid correspondant au login) et de confiance.")) print(check_keys(options) and "Base de clés ok" or "Erreurs dans la base") -def my_update_keys(options): - """Met à jour les clés existantes et affiche le résultat""" - print(update_keys(options)) +def update_keys(options): + """ + Action to receive server known keys + """ + # fetch all keys from server + keys = all_keys(options) + + # receive each key + for name, fpr in keys.values(): + receive_key(fpr) def recrypt_files(options, strict=False): @@ -867,7 +796,7 @@ def main(): '--update-keys', action='store_const', dest='action', - const=my_update_keys, + const=update_keys, help=_("update keys"), ) action_grp.add_argument( @@ -881,7 +810,7 @@ def main(): '--list-servers', action='store_const', dest='action', - const=show_servers, + const=list_servers, help=_("list servers"), ) action_grp.add_argument( @@ -922,9 +851,9 @@ def main(): options.serverdata = config[options.server] # On parse les roles fournis, et il doivent exister, ne pas être -w… # parse_roles s'occupe de ça - # NB : ça nécessite de se connecter au serveur, or, pour show_servers on n'en a pas besoin + # NB : ça nécessite de se connecter au serveur, or, pour list_servers on n'en a pas besoin # Il faudrait ptêtre faire ça plus proprement, en attendant, je ducktape. - if options.action != show_servers: + if options.action != list_servers: options.roles = parse_roles(options) # Si l'utilisateur a demandé une action qui nécessite un nom de fichier, diff --git a/cpasswords/crypto.py b/cpasswords/crypto.py new file mode 100644 index 0000000000000000000000000000000000000000..31053aacaea400410239bd9864df8c2bafa4691e --- /dev/null +++ b/cpasswords/crypto.py @@ -0,0 +1,71 @@ +""" +GnuPG abstraction layer + +Copyright (C) 2010-2020 Cr@ns <roots@crans.org> +Authors : Daniel Stan <daniel.stan@crans.org> + Vincent Le Gallic <legallic@crans.org> + Alexandre Iooss <erdnaxe@crans.org> +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import subprocess +import logging + +import gpg + +# Local logger +log = logging.getLogger(__name__) + + +def decrypt(ciphertext: str): + """ + Return decrypted content + """ + log.info("Decrypting using GnuPG") + with gpg.Context() as c: + plaintext, _, _ = c.decrypt(ciphertext.encode("utf-8")) + return plaintext.decode("utf-8") + + +def encrypt(content: str, keys: []) -> str: + """ + Return encrypted content for keys + """ + log.info("Encrypting using GnuPG") + with gpg.Context() as c: + c.armor = True + cipher, _, _ = c.encrypt(content.encode("utf-8"), keys) + return cipher.decode("utf-8") + + +def receive_key(fpr: str): + """ + Download key from fingerprint + """ + full_command = ['gpg', '--recv-keys', fpr] + log.info("Running `%s`" % " ".join(full_command)) + return subprocess.run(full_command) + + +def check_key_validity(key, email: str) -> bool: + """ + Check key identities email and trust level + Return true if can be trusted + """ + log.info("Checking %s key with email %s" % (key.fpr, email)) + for uid in key.uids: + if email == uid.email and not uid.revoked and not uid.invalid \ + and uid.validity >= gpg.constants.validity.FULL: + return True + + # no trusted valid uid were found + return False + + +def get_key_from_fingerprint(fpr): + """ + Get GnuPG key by fingerprint + """ + log.info("Getting key corresponding to %s" % fpr) + with gpg.Context() as c: + return c.get_key(fpr) diff --git a/cpasswords/gpg.py b/cpasswords/gpg.py deleted file mode 100644 index 8f1f8310d4786983974305da9f79f5e509525a6d..0000000000000000000000000000000000000000 --- a/cpasswords/gpg.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -GPG binding - -Copyright (C) 2010-2020 Cr@ns <roots@crans.org> -Authors : Daniel Stan <daniel.stan@crans.org> - Vincent Le Gallic <legallic@crans.org> - Alexandre Iooss <erdnaxe@crans.org> -SPDX-License-Identifier: GPL-3.0-or-later -""" - -import sys -import subprocess -import logging -import time -import datetime - -# Local logger -log = logging.getLogger(__name__) - -# GPG Definitions -#: Path du binaire gpg -GPG = 'gpg' - -#: Mapping (lettre de trustlevel) -> (signification, faut-il faire confiance à la clé) -GPG_TRUSTLEVELS = { - "-": ("inconnue (pas de valeur assignée)", False), - "o": ("inconnue (nouvelle clé)", False), - "i": ("invalide (self-signature manquante ?)", False), - "n": ("nulle (il ne faut pas faire confiance à cette clé)", False), - "m": ("marginale (pas assez de lien de confiance vers cette clé)", False), - "f": ("entière (clé dans le réseau de confiance)", True), - "u": ("ultime (c'est probablement ta clé)", True), - "r": ("révoquée", False), - "e": ("expirée", False), - "q": ("non définie", False), -} - - -def _parse_timestamp(string, canbenone=False): - """Interprète ``string`` comme un timestamp depuis l'Epoch.""" - if string == '' and canbenone: - return None - return datetime.datetime(*time.localtime(int(string))[:7]) - - -def _parse_pub(data): - """Interprète une ligne ``pub:``""" - d = { - 'trustletter': data[1], - 'length': int(data[2]), - 'algorithm': int(data[3]), - 'longid': data[4], - 'signdate': _parse_timestamp(data[5]), - 'expiredate': _parse_timestamp(data[6], canbenone=True), - 'ownertrustletter': data[8], - 'capabilities': data[11], - } - return d - - -def _parse_uid(data): - """Interprète une ligne ``uid:``""" - d = { - 'trustletter': data[1], - 'signdate': _parse_timestamp(data[5], canbenone=True), - 'hash': data[7], - 'uid': data[9], - } - return d - - -def _parse_fpr(data): - """Interprète une ligne ``fpr:``""" - d = { - 'fpr': data[9], - } - return d - - -def _parse_sub(data): - """Interprète une ligne ``sub:``""" - d = { - 'trustletter': data[1], - 'length': int(data[2]), - 'algorithm': int(data[3]), - 'longid': data[4], - 'signdate': _parse_timestamp(data[5]), - 'expiredate': _parse_timestamp(data[6], canbenone=True), - 'capabilities': data[11], - } - return d - - -#: Functions to parse the recognized fields -GPG_PARSERS = { - 'pub': _parse_pub, - 'uid': _parse_uid, - 'fpr': _parse_fpr, - 'sub': _parse_sub, -} - - -def _parse_keys(gpgout, debug=False): - """ - Parse l'output d'un listing de clés gpg. - TODO: à déméler et simplifier - """ - ring = {} - # Valeur utilisée pour dire "cet objet n'a pas encore été rencontré pendant le parsing" - init_value = "initialize" - current_pub = init_value - current_sub = init_value - for line in iter(gpgout.readline, ''): - # La doc dit que l'output est en UTF-8 «regardless of any --display-charset setting» - try: - line = line.decode("utf-8") - except UnicodeDecodeError: - try: - line = line.decode("iso8859-1") - except UnicodeDecodeError: - line = line.decode("iso8859-1", "ignore") - log.warning( - "gpg is telling shit, it is neither ISO-8859-1 nor UTF-8. Dropping!") - line = line.split(":") - field = line[0] - if field in GPG_PARSERS.keys(): - log.debug("begin loop, met %s :" % (field)) - log.debug("current_pub : %r" % current_pub) - log.debug("current_sub : %r" % current_sub) - try: - content = GPG_PARSERS[field](line) - except KeyError: - log.error("*** FAILED *** Line: %s", line) - raise - if field == "pub": - # Nouvelle clé - # On sauvegarde d'abord le dernier sub (si il y en a un) dans son pub parent - if current_sub != init_value: - current_pub["subkeys"].append(current_sub) - # Ensuite on sauve le pub précédent (si il y en a un) dans le ring - if current_pub != init_value: - ring[current_pub["fpr"]] = current_pub - # On place le nouveau comme pub courant - current_pub = content - # Par défaut, il n'a ni subkeys, ni uids - current_pub["subkeys"] = [] - current_pub["uids"] = [] - # On oublié l'éventuel dernier sub rencontré - current_sub = init_value - elif field == "fpr": - if current_sub != init_value: - # On a lu un sub depuis le dernier pub, donc le fingerprint est celui du dernier sub rencontré - current_sub["fpr"] = content["fpr"] - else: - # Alors c'est le fingerprint du pub - current_pub["fpr"] = content["fpr"] - elif field == "uid": - current_pub["uids"].append(content) - elif field == "sub": - # Nouvelle sous-clé - # D'abord on sauvegarde la précédente (si il y en a une) dans son pub parent - if current_sub != init_value: - current_pub["subkeys"].append(current_sub) - # On place la nouvelle comme sub courant - current_sub = content - log.debug("current_pub : %r" % current_pub) - log.debug("current_sub : %r" % current_sub) - log.debug("parsed object : %r" % content) - # À la fin, il faut sauvegarder les derniers (sub, pub) rencontrés, - # parce que leur sauvegarde n'a pas encore été déclenchée - if current_sub != init_value: - current_pub["subkeys"].append(current_sub) - if current_pub != init_value: - ring[current_pub["fpr"]] = current_pub - return ring - - -def _gpg(*argv): - """ - Run GPG and return its standard input and output. - """ - full_command = [GPG] + list(argv) - log.info("Running `%s`" % " ".join(full_command)) - proc = subprocess.Popen( - full_command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=sys.stderr, - close_fds=True, - ) - return proc.stdin, proc.stdout - - -def decrypt(content): - """ - Return decrypted content - """ - stdin, stdout = _gpg('-d') - stdin.write(content.encode("utf-8")) - stdin.close() - return stdout.read().decode("utf-8") - - -def encrypt(content: str, fingerprints: [str]) -> str: - """ - Return encrypted content for fingerprints - """ - stdin, stdout = _gpg('--armor', '-es', *fingerprints) - stdin.write(content.encode("utf-8")) - stdin.close() - return stdout.read().decode("utf-8") - - -def receive_keys(keys): - return _gpg('--recv-keys', *keys) - - -def list_keys(): - # It is not an error, you need two --with-fingerprint - # to get subkeys fingerprints. - _, out = _gpg('--list-keys', '--with-colons', '--fixed-list-mode', - '--with-fingerprint', '--with-fingerprint') - return _parse_keys(out) diff --git a/cpasswords/locale.py b/cpasswords/locale.py new file mode 100644 index 0000000000000000000000000000000000000000..06af1f8dbe2dfbaac2c35d93994b2952ba6b4dcd --- /dev/null +++ b/cpasswords/locale.py @@ -0,0 +1,18 @@ +""" +Small gettext wrapper + +Copyright (C) 2010-2020 Cr@ns <roots@crans.org> +Authors : Daniel Stan <daniel.stan@crans.org> + Vincent Le Gallic <legallic@crans.org> + Alexandre Iooss <erdnaxe@crans.org> +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import gettext + +from pkg_resources import resource_filename + +# Load locale +gettext.bindtextdomain('messages', resource_filename("cpasswords", "locale")) +gettext.textdomain('messages') +_ = gettext.gettext diff --git a/cpasswords/locale/fr/LC_MESSAGES/messages.po b/cpasswords/locale/fr/LC_MESSAGES/messages.po index 5a0fd74215464c154dc4dcbce274f4ec8d00b572..b14b01a9b69c948e95df110c9a0a18e2972e1e52 100644 --- a/cpasswords/locale/fr/LC_MESSAGES/messages.po +++ b/cpasswords/locale/fr/LC_MESSAGES/messages.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-14 12:15+0200\n" +"POT-Creation-Date: 2020-04-16 10:01+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: cpasswords/client.py:53 +#: cpasswords/client.py:47 #, python-format msgid "" "%s/clientconfig.ini could not be found or read.\n" @@ -23,106 +23,114 @@ msgstr "" "Veuillez copier `docs/clientconfig.example.ini` à partir des sources et le " "personnaliser." -#: cpasswords/client.py:131 +#: cpasswords/client.py:176 #, python-format -msgid "Writing to stdin: %s" -msgstr "Écriture dans stdin: %s" +msgid "key correponding to fingerprint %s and mail %s not found" +msgstr "clé correspondante à l'emprunte %s et au mail %s non trouvée" -#: cpasswords/client.py:141 -#, python-format -msgid "Wrong server return code %s, error is %s" -msgstr "Mauvaise code de retour serveur %s, l'erreur est %s" +#: cpasswords/client.py:179 +msgid "Do you want to try to import it now ?" +msgstr "Voulez-vous tenter de l'importer maintenant ?" -#: cpasswords/client.py:148 -msgid "Error while parsing JSON" -msgstr "Erreur lors du parsing du JSON" +#: cpasswords/client.py:186 cpasswords/client.py:190 +#, python-format +msgid "Dropping key from %s" +msgstr "On drop la clé de %s, (V) (;,,;) (V) drop the key" -#: cpasswords/client.py:405 +#: cpasswords/client.py:311 msgid "Available files:" msgstr "Liste des fichiers disponibles :" -#: cpasswords/client.py:410 +#: cpasswords/client.py:316 #, python-format msgid "--Mes roles: %s" msgstr "" -#: cpasswords/client.py:415 +#: cpasswords/client.py:323 msgid "Fichier corrompus :" -msgstr "" +msgstr "Corrumpted files:" -#: cpasswords/client.py:425 -msgid "Liste des roles disponibles" -msgstr "" +#: cpasswords/client.py:335 +msgid "Listing available roles and their users" +msgstr "Liste des rôles disponibles et leurs utilisateurs" -#: cpasswords/client.py:436 -msgid "Liste des serveurs disponibles" -msgstr "" +#: cpasswords/client.py:348 +msgid "List of configured servers" +msgstr "Liste des serveurs configurés" + +#: cpasswords/client.py:350 cpasswords/client.py:351 +msgid "undefined" +msgstr "non défini" -#: cpasswords/client.py:451 +#: cpasswords/client.py:362 msgid "" "Appuyez sur Entrée pour récupérer le contenu précédent du presse papier." msgstr "" -#: cpasswords/client.py:512 +#: cpasswords/client.py:414 msgid "La clé a été mise dans l'agent ssh" msgstr "" -#: cpasswords/client.py:666 +#: cpasswords/client.py:469 +msgid "File not found on server" +msgstr "Fichier non trouvé sur le server" + +#: cpasswords/client.py:575 msgid "" "Vérification que les clés sont valides (uid correspondant au login) et de " "confiance." msgstr "" -#: cpasswords/client.py:713 +#: cpasswords/client.py:629 #, python-format msgid "" "Vous vous apprêtez à rechiffrer les fichiers suivants :\n" "%s" msgstr "" -#: cpasswords/client.py:731 +#: cpasswords/client.py:647 msgid "Aucun fichier n'a besoin d'être rechiffré" msgstr "" -#: cpasswords/client.py:772 +#: cpasswords/client.py:688 msgid "You need to provide a filename with this command" msgstr "Un nom de fichier est nécessaire avec cette commande" -#: cpasswords/client.py:781 +#: cpasswords/client.py:697 msgid "Group passwords manager based on GPG." msgstr "Gestion de mots de passe partagés grâce à GPG." -#: cpasswords/client.py:787 +#: cpasswords/client.py:703 msgid "name of file to show or edit" msgstr "nom du fichier à lire ou éditer" -#: cpasswords/client.py:793 +#: cpasswords/client.py:709 msgid "verbose mode, multiple -v options increase verbosity" msgstr "mode verbeux, multiplier l'option -v pour augmenter" -#: cpasswords/client.py:799 +#: cpasswords/client.py:715 msgid "silent mode: hide errors, overrides verbosity" msgstr "mode silencieux: cache les erreurs, ignore la verbosité" -#: cpasswords/client.py:805 +#: cpasswords/client.py:721 msgid "select another server than DEFAULT" msgstr "sélectionne un autre server que DEFAULT" -#: cpasswords/client.py:812 +#: cpasswords/client.py:728 msgid "do not try to store password in clipboard" msgstr "n'essaie pas de stocker le mot de passe dans le presse papier" -#: cpasswords/client.py:818 +#: cpasswords/client.py:734 msgid "do not prompt confirmation" msgstr "ne pas demander confirmation" -#: cpasswords/client.py:825 +#: cpasswords/client.py:741 msgid "need --force, drop untrusted keys without confirmation." msgstr "" "avec --force, ignore les clés en lesquels on n'a pas confiance sans " "confirmation." -#: cpasswords/client.py:831 +#: cpasswords/client.py:747 msgid "" "specify for which roles to crypt (default to all roles, or do not change if " "editing)" @@ -130,42 +138,70 @@ msgstr "" "spécifier pour quels rôles chiffrer (défaut à tous les rôles, ou ne les " "change pas si édition)" -#: cpasswords/client.py:843 +#: cpasswords/client.py:759 msgid "read file (default)" msgstr "lit le fichier (défaut)" -#: cpasswords/client.py:850 +#: cpasswords/client.py:766 msgid "edit (or create) file" msgstr "édite (ou crée) le fichier" -#: cpasswords/client.py:857 +#: cpasswords/client.py:773 msgid "erase file" msgstr "efface le fichier" -#: cpasswords/client.py:864 +#: cpasswords/client.py:780 msgid "list files" msgstr "liste les fichiers" -#: cpasswords/client.py:870 +#: cpasswords/client.py:786 msgid "restore corrumpted files" msgstr "restaure les fichiers corrompus" -#: cpasswords/client.py:877 +#: cpasswords/client.py:793 msgid "check keys" msgstr "vérifie les clés" -#: cpasswords/client.py:884 +#: cpasswords/client.py:800 msgid "update keys" msgstr "met à jour les clés" -#: cpasswords/client.py:891 +#: cpasswords/client.py:807 msgid "list existing roles" msgstr "liste les rôles existants" -#: cpasswords/client.py:898 +#: cpasswords/client.py:814 msgid "list servers" msgstr "liste les serveurs" -#: cpasswords/client.py:905 +#: cpasswords/client.py:821 msgid "recrypt all files having a role listed in --roles" msgstr "rechiffre tous les fichiers ayant un rôle listé dans --roles" + +#: cpasswords/remote.py:35 +msgid "An error occured during SSH connection, debug with -vv" +msgstr "Une erreur s'est produite pendant la connexion SSH, débogguez avec -vv" + +#: cpasswords/remote.py:59 +#, python-format +msgid "Running command `%s`" +msgstr "Lancement de la commande `%s`" + +#: cpasswords/remote.py:64 +#, python-format +msgid "Writing to stdin: %s" +msgstr "Écriture dans stdin: %s" + +#: cpasswords/remote.py:74 +#, python-format +msgid "Wrong server return code %s, error is %s" +msgstr "Mauvaise code de retour serveur %s, l'erreur est %s" + +#: cpasswords/remote.py:81 +msgid "Error while parsing JSON" +msgstr "Erreur lors du parsing du JSON" + +#: cpasswords/remote.py:84 +#, python-format +msgid "Server returned %s" +msgstr "Le serveur a renvoyé %s" diff --git a/cpasswords/remote.py b/cpasswords/remote.py new file mode 100644 index 0000000000000000000000000000000000000000..580cd1fb2e5763687aded8c3caa5b65a9b1ad0d1 --- /dev/null +++ b/cpasswords/remote.py @@ -0,0 +1,85 @@ +""" +Utils to run commands on remote server + +Copyright (C) 2010-2020 Cr@ns <roots@crans.org> +Authors : Daniel Stan <daniel.stan@crans.org> + Vincent Le Gallic <legallic@crans.org> + Alexandre Iooss <erdnaxe@crans.org> +SPDX-License-Identifier: GPL-3.0-or-later +""" + +from functools import lru_cache +import json +import logging + +from paramiko.client import SSHClient +from paramiko.ssh_exception import SSHException + +from .locale import _ + +# Local logger +log = logging.getLogger(__name__) + + +@lru_cache() +def create_ssh_client(host): + """ + Create a SSH client with paramiko module + """ + # Create SSH client with system host keys and agent + client = SSHClient() + client.load_system_host_keys() + try: + client.connect(host) + except SSHException: + log.error(_("An error occured during SSH connection, debug with -vv")) + raise + + return client + + +def remote_command(options, command, arg=None, stdin_contents=None): + """ + Execute remote command and return output + """ + if "host" not in options.serverdata: + log.error("Missing parameter `host` in active server configuration") + exit(1) + client = create_ssh_client(str(options.serverdata['host'])) + + # Build command + if "remote_cmd" not in options.serverdata: + log.error("Missing parameter `remote_cmd` in active server configuration") + exit(1) + remote_cmd = options.serverdata['remote_cmd'] + " " + command + if arg: + remote_cmd += " " + arg + + # Run command and timeout after 10s + log.info(_("Running command `%s`") % remote_cmd) + stdin, stdout, stderr = client.exec_command(remote_cmd, timeout=10) + + # Write + if stdin_contents is not None: + log.info(_("Writing to stdin: %s") % stdin_contents) + stdin.write(json.dumps(stdin_contents)) + stdin.flush() + + # Return code == 0 if success + ret = stdout.channel.recv_exit_status() + if ret != 0: + err = "" + if stderr.channel.recv_stderr_ready(): + err = stderr.read() + log.error(_("Wrong server return code %s, error is %s") % (ret, err)) + exit(ret) + + # Decode directly read buffer + try: + answer = json.load(stdout) + except ValueError: + log.error(_("Error while parsing JSON")) + exit(42) + + log.debug(_("Server returned %s") % answer) + return answer diff --git a/docs/update_locales.sh b/docs/update_locales.sh index 1253970782b1c8cd93155c290874d62142c7885d..cd05244194068e2307a078a50bd4e5445a33909d 100755 --- a/docs/update_locales.sh +++ b/docs/update_locales.sh @@ -1,5 +1,5 @@ #!/bin/bash # Execute docs/update_locales.sh from repo root -xgettext --from-code utf-8 -o messages.pot cpasswords/client.py +xgettext --from-code utf-8 -o messages.pot cpasswords/*.py msgmerge --update cpasswords/locale/fr/LC_MESSAGES/messages.po messages.pot rm messages.pot diff --git a/setup.py b/setup.py index 8e152f6a54520416106e8d52bbb6ee8db48840f5..f53f313a67f2e78fc24bd48fe80e965431ad74a8 100755 --- a/setup.py +++ b/setup.py @@ -50,7 +50,8 @@ setup( include_package_data=True, install_requires=[ 'paramiko>=2.2', - 'pyperclip>=1.7.0' + 'pyperclip>=1.7.0', + 'gpg' ], entry_points={ "console_scripts": [