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": [