client.py 43 KB
Newer Older
Krokmou's avatar
Krokmou committed
1
#!/usr/bin/env python2
Daniel STAN's avatar
init  
Daniel STAN committed
2
# -*- encoding: utf-8 -*-
Vincent Le gallic's avatar
Vincent Le gallic committed
3 4 5 6 7 8 9

"""Gestion centralisée des mots de passe avec chiffrement GPG

Copyright (C) 2010-2013 Cr@ns <roots@crans.org>
Authors : Daniel Stan <daniel.stan@crans.org>
          Vincent Le Gallic <legallic@crans.org>
"""
Daniel STAN's avatar
init  
Daniel STAN committed
10

Vincent Le gallic's avatar
Vincent Le gallic committed
11 12
from __future__ import print_function

13
# Import builtins
Daniel STAN's avatar
init  
Daniel STAN committed
14 15 16
import sys
import subprocess
import json
Daniel STAN's avatar
Daniel STAN committed
17 18 19
import tempfile
import os
import atexit
Daniel STAN's avatar
Daniel STAN committed
20
import argparse
Daniel STAN's avatar
Daniel STAN committed
21
import re
22 23
import random
import string
24
import time
25
import datetime
Daniel STAN's avatar
Daniel STAN committed
26
import copy
27 28

# Import de la config
29
envvar = "CRANSPASSWORDS_CLIENT_CONFIG_DIR"
30
try:
31 32 33
    # Oui, le nom de la commande est dans la config, mais on n'a pas encore accès à la config
    bootstrap_cmd_name = os.path.split(sys.argv[0])[1]
    sys.path.append(os.path.expanduser("~/.config/%s/" % (bootstrap_cmd_name,)))
34 35
    import clientconfig as config
except ImportError:
Daniel STAN's avatar
Daniel STAN committed
36 37 38
    ducktape_display_error = sys.stderr.isatty() and \
              not any([opt in sys.argv for opt in ["-q", "--quiet"]]) and \
              __name__ == '__main__'
39 40 41 42
    envspecified = os.getenv(envvar, None)
    if envspecified is None:
        if ducktape_display_error:
            sys.stderr.write(u"Va lire le fichier README.\n".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
43
            sys.exit(1)
44 45 46 47 48 49 50 51 52
    else:
        # On a spécifié à la main le dossier de conf
        try:
            sys.path.append(envspecified)
            import clientconfig as config
        except ImportError:
            if ducktape_display_error:
                sys.stderr.write(u"%s est spécifiée, mais aucune config pour le client ne peut être importée." % (envvar))
                sys.exit(1)
Daniel STAN's avatar
init  
Daniel STAN committed
53

54 55
#: Pattern utilisé pour détecter la ligne contenant le mot de passe dans les fichiers
pass_regexp = re.compile('[\t ]*pass(?:word)?[\t ]*:[\t ]*(.*)\r?\n?$',
Daniel STAN's avatar
Daniel STAN committed
56 57
        flags=re.IGNORECASE)

Daniel STAN's avatar
Daniel STAN committed
58 59 60 61

## How many seconds before password is erased from clipboard
SHOW_TIMEOUT=30

Daniel STAN's avatar
init  
Daniel STAN committed
62
## GPG Definitions
63
#: Path du binaire gpg
64
GPG = 'gpg'
65 66

#: Paramètres à fournir à gpg en fonction de l'action désirée
Daniel STAN's avatar
init  
Daniel STAN committed
67
GPG_ARGS = {
Vincent Le gallic's avatar
Vincent Le gallic committed
68 69 70
    'decrypt' : ['-d'],
    'encrypt' : ['--armor', '-es'],
    'receive-keys' : ['--recv-keys'],
71 72
    'list-keys' : ['--list-keys', '--with-colons', '--fixed-list-mode',
                   '--with-fingerprint', '--with-fingerprint'], # Ce n'est pas une erreur. Il faut 2 --with-fingerprint pour avoir les fingerprints des subkeys.
Daniel STAN's avatar
init  
Daniel STAN committed
73
    }
74 75

#: Mapping (lettre de trustlevel) -> (signification, faut-il faire confiance à la clé)
76
GPG_TRUSTLEVELS = {
77 78 79
                    u"-" : (u"inconnue (pas de valeur assignée)", False),
                    u"o" : (u"inconnue (nouvelle clé)", False),
                    u"i" : (u"invalide (self-signature manquante ?)", False),
80 81 82 83
                    u"n" : (u"nulle (il ne faut pas faire confiance à cette clé)", False),
                    u"m" : (u"marginale (pas assez de lien de confiance vers cette clé)", False),
                    u"f" : (u"entière (clé dans le réseau de confiance)", True),
                    u"u" : (u"ultime (c'est probablement ta clé)", True),
84 85
                    u"r" : (u"révoquée", False),
                    u"e" : (u"expirée", False),
86
                    u"q" : (u"non définie", False),
87
                  }
Daniel STAN's avatar
Daniel STAN committed
88

89
def gpg(options, command, args=None):
Daniel STAN's avatar
init  
Daniel STAN committed
90
    """Lance gpg pour la commande donnée avec les arguments
91
    donnés. Renvoie son entrée standard et sa sortie standard."""
Daniel STAN's avatar
init  
Daniel STAN committed
92 93 94 95
    full_command = [GPG]
    full_command.extend(GPG_ARGS[command])
    if args:
        full_command.extend(args)
96
    if options.verbose:
97
        stderr = sys.stderr
Daniel STAN's avatar
Daniel STAN committed
98
    else:
99
        stderr = subprocess.PIPE
Daniel STAN's avatar
Daniel STAN committed
100
        full_command.extend(['--debug-level=1'])
101 102
    if options.verbose:
        print(" ".join(full_command))
Daniel STAN's avatar
init  
Daniel STAN committed
103 104 105
    proc = subprocess.Popen(full_command,
                            stdin = subprocess.PIPE,
                            stdout = subprocess.PIPE,
Daniel STAN's avatar
Daniel STAN committed
106
                            stderr = stderr,
Daniel STAN's avatar
init  
Daniel STAN committed
107
                            close_fds = True)
108
    if not options.verbose:
Daniel STAN's avatar
Daniel STAN committed
109
        proc.stderr.close()
Daniel STAN's avatar
init  
Daniel STAN committed
110 111
    return proc.stdin, proc.stdout

112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
def _parse_timestamp(string, canbenone=False):
    """Interprète ``string`` comme un timestamp depuis l'Epoch."""
    if string == u'' and canbenone:
        return None
    return datetime.datetime(*time.localtime(int(string))[:7])

def _parse_pub(data):
    """Interprète une ligne ``pub:``"""
    d = {
        u'trustletter' : data[1],
        u'length' : int(data[2]),
        u'algorithm' : int(data[3]),
        u'longid' : data[4],
        u'signdate' : _parse_timestamp(data[5]),
        u'expiredate' : _parse_timestamp(data[6], canbenone=True),
        u'ownertrustletter' : data[8],
        u'capabilities' : data[11],
        }
    return d

def _parse_uid(data):
    """Interprète une ligne ``uid:``"""
    d = {
        u'trustletter' : data[1],
        u'signdate' : _parse_timestamp(data[5], canbenone=True),
        u'hash' : data[7],
        u'uid' : data[9],
        }
    return d

def _parse_fpr(data):
    """Interprète une ligne ``fpr:``"""
    d = {
        u'fpr' : data[9],
        }
    return d

def _parse_sub(data):
    """Interprète une ligne ``sub:``"""
    d = {
        u'trustletter' : data[1],
        u'length' : int(data[2]),
        u'algorithm' : int(data[3]),
        u'longid' : data[4],
        u'signdate' : _parse_timestamp(data[5]),
        u'expiredate' : _parse_timestamp(data[6], canbenone=True),
        u'capabilities' : data[11],
        }
    return d

#: Functions to parse the recognized fields
GPG_PARSERS = {
    u'pub' : _parse_pub,
    u'uid' : _parse_uid,
    u'fpr' : _parse_fpr,
    u'sub' : _parse_sub,
     }

def _gpg_printdebug(d):
    print("current_pub : %r" % d.get("current_pub", None))
    print("current_sub : %r" % d.get("current_sub", None))

def parse_keys(gpgout, debug=False):
    """Parse l'output d'un listing de clés gpg."""
    ring = {}
    init_value = u"initialize" # Valeur utilisée pour dire "cet objet n'a pas encore été rencontré pendant le parsing"
    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»
182 183 184 185 186 187 188 189 190
        try:
            line = line.decode("utf-8")
        except UnicodeDecodeError:
            try:
                line = line.decode("iso8859-1")
            except UnicodeDecodeError:
                line = line.decode("iso8859-1", "ignore")
                if not options.quiet:
                    print("gpg raconte de la merde, c'est ni de l'ISO-8859-1 ni de l'UTF-8, je droppe, ça risque de foirer plus tard.", sys.stderr)
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
        line = line.split(":")
        field = line[0]
        if field in GPG_PARSERS.keys():
            if debug:
                print("\nbegin loop. met %s :" % (field))
                _gpg_printdebug(locals())
            try:
                content = GPG_PARSERS[field](line)
            except:
                print("*** FAILED ***")
                print(line)
                raise
            if field == u"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[u"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[u"subkeys"] = []
                current_pub[u"uids"] = []
                # On oublié l'éventuel dernier sub rencontré
                current_sub = init_value
            elif field == u"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[u"fpr"] = content[u"fpr"]
                else:
                    # Alors c'est le fingerprint du pub
                    current_pub[u"fpr"] = content[u"fpr"]
            elif field == u"uid":
                current_pub[u"uids"].append(content)
            elif field == u"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[u"subkeys"].append(current_sub)
                # On place la nouvelle comme sub courant
                current_sub = content
            if debug:
                _gpg_printdebug(locals())
                print("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[u"fpr"]] = current_pub
    return ring
244 245 246 247 248 249 250

class simple_memoize(object):
    """ Memoization/Lazy """
    def __init__(self, f):
        self.f = f
        self.val = None

251 252 253 254 255
    def __call__(self, *args, **kwargs):
        """Attention ! On peut fournir des paramètres, mais comme on mémorise pour la prochaine fois,
           si on rappelle avec des paramètres différents, on aura quand même la même réponse.
           Pour l'instant, on s'en fiche puisque les paramètres ne changent pas d'un appel au suivant,
           mais il faudra s'en préoccuper si un jour on veut changer le comportement."""
Vincent Le gallic's avatar
Vincent Le gallic committed
256
        if self.val == None:
257
            self.val = self.f(*args, **kwargs)
258 259 260 261 262 263
        # On évite de tout deepcopier. Typiquement, un subprocess.Popen
        # ne devrait pas l'être (comme dans get_keep_alive_connection)
        if type(self.val) in [dict, list]:
            return copy.deepcopy(self.val)
        else:
            return self.val
264

Vincent Le gallic's avatar
Vincent Le gallic committed
265

Daniel STAN's avatar
init  
Daniel STAN committed
266 267 268
######
## Remote commands

269 270 271 272 273 274 275 276 277
def remote_proc(options, command, arg=None):
    """
    Fabrique un process distant pour communiquer avec le serveur.
    Cela consiste à lancer une commande (indiquée dans la config)
    qui va potentiellement lancer ssh.
    ``command`` désigne l'action à envoyer au serveur
    ``arg`` est une chaîne (str) accompagnant la commande en paramètre
    ``options`` contient la liste usuelle d'options
    """
278
    full_command = list(options.serverdata['server_cmd'])
Daniel STAN's avatar
init  
Daniel STAN committed
279 280 281
    full_command.append(command)
    if arg:
        full_command.append(arg)
282 283 284 285

    if options.verbose and not options.quiet:
        print("Running command %s ..." % " ".join(full_command))

Daniel STAN's avatar
init  
Daniel STAN committed
286 287 288 289 290
    proc = subprocess.Popen(full_command,
                            stdin = subprocess.PIPE,
                            stdout = subprocess.PIPE,
                            stderr = sys.stderr,
                            close_fds = True)
291
    return proc
Daniel STAN's avatar
init  
Daniel STAN committed
292

293 294 295 296 297 298 299 300 301
@simple_memoize
def get_keep_alive_connection(options):
    """Fabrique un process parlant avec le serveur suivant la commande
    'keep-alive'. On utilise une fonction séparée pour cela afin
    de memoizer le résultat, et ainsi utiliser une seule connexion"""
    proc = remote_proc(options, 'keep-alive', None)
    atexit.register(proc.stdin.close)
    return proc

302
def remote_command(options, command, arg=None, stdin_contents=None):
Daniel STAN's avatar
init  
Daniel STAN committed
303 304
    """Exécute la commande distante, et retourne la sortie de cette
    commande"""
305
    detail = options.verbose and not options.quiet
306
    keep_alive = options.serverdata.get('keep-alive', False)
307

308 309 310 311 312 313 314 315 316 317 318
    if keep_alive:
        conn = get_keep_alive_connection(options)
        args = filter(None, [arg, stdin_contents])
        msg = {u'action': unicode(command), u'args': args }
        conn.stdin.write('%s\n' % json.dumps(msg))
        conn.stdin.flush()
        raw_out = conn.stdout.readline()
    else:
        proc = remote_proc(options, command, arg)
        if stdin_contents is not None:
            proc.stdin.write(json.dumps(stdin_contents))
Daniel STAN's avatar
Daniel STAN committed
319 320 321 322 323
            proc.stdin.flush()

        raw_out, raw_err = proc.communicate()
        ret = proc.returncode

324 325 326 327 328 329 330 331
        if ret != 0:
            if not options.quiet:
                print((u"Mauvais code retour côté serveur, voir erreur " +
                       u"ci-dessus").encode('utf-8'),
                      file=sys.stderr)
                if detail:
                    print("raw_output: %s" % raw_out)
            sys.exit(ret)
332
    try:
333
        answer = json.loads(raw_out.strip())
334 335 336 337
    except ValueError:
        if not options.quiet:
            print(u"Impossible de parser le résultat".encode('utf-8'),
                  file=sys.stderr)
338
            if detail:
339 340
                print("raw_output: %s" % raw_out)
            sys.exit(42)
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
    if not keep_alive:
        return answer
    else:
        try:
            if answer[u'status'] != u'ok':
                raise KeyError('Bad answer status')
            return answer[u'content']
        except KeyError:
            if not options.quiet:
                print(u"Réponse erronée du serveur".encode('utf-8'),
                    file=sys.stderr)
            if detail:
                print("answer: %s" % repr(answer))
            sys.exit(-1)

Daniel STAN's avatar
init  
Daniel STAN committed
356

357
@simple_memoize
358
def all_keys(options):
Daniel STAN's avatar
init  
Daniel STAN committed
359
    """Récupère les clés du serveur distant"""
360
    return remote_command(options, "listkeys")
Daniel STAN's avatar
init  
Daniel STAN committed
361

362
@simple_memoize
363
def all_roles(options):
Daniel STAN's avatar
init  
Daniel STAN committed
364
    """Récupère les roles du serveur distant"""
365
    return remote_command(options, "listroles")
Daniel STAN's avatar
init  
Daniel STAN committed
366

367
@simple_memoize
368
def all_files(options):
Daniel STAN's avatar
init  
Daniel STAN committed
369
    """Récupère les fichiers du serveur distant"""
370
    return remote_command(options, "listfiles")
Daniel STAN's avatar
init  
Daniel STAN committed
371

372 373 374 375 376 377
@simple_memoize
def restore_all_files(options):
    """Récupère les fichiers du serveur distant"""
    return remote_command(options, "restorefiles")


378
def get_files(options, filenames):
379
    """Récupère le contenu des fichiers distants"""
380
    return remote_command(options, "getfiles", stdin_contents=filenames)
Daniel STAN's avatar
init  
Daniel STAN committed
381

382
def put_files(options, files):
383
    """Dépose les fichiers sur le serveur distant"""
384
    return remote_command(options, "putfiles", stdin_contents=files)
Vincent Le gallic's avatar
Vincent Le gallic committed
385

Daniel STAN's avatar
init  
Daniel STAN committed
386 387
def rm_file(filename):
    """Supprime le fichier sur le serveur distant"""
388
    return remote_command(options, "rmfile", filename)
Daniel STAN's avatar
init  
Daniel STAN committed
389

390
@simple_memoize
391 392 393
def get_my_roles(options):
    """Retourne la liste des rôles de l'utilisateur, et également la liste des rôles dont il possède le role-w."""
    allroles = all_roles(options)
394 395
    distant_username = allroles.pop("whoami")
    my_roles = [r for (r, users) in allroles.iteritems() if distant_username in users]
396 397
    my_roles_w = [r[:-2] for r in my_roles if r.endswith("-w")]
    return (my_roles, my_roles_w)
Daniel STAN's avatar
Daniel STAN committed
398

399
def gen_password():
Vincent Le gallic's avatar
Vincent Le gallic committed
400
    """Génère un mot de passe aléatoire"""
401 402 403
    random.seed(datetime.datetime.now().microsecond)
    chars = string.letters + string.digits + '/=+*'
    length = 15
404
    return u''.join([random.choice(chars) for _ in xrange(length)])
405

Daniel STAN's avatar
init  
Daniel STAN committed
406 407 408
######
## Local commands

409
def update_keys(options):
Daniel STAN's avatar
init  
Daniel STAN committed
410
    """Met à jour les clés existantes"""
411

412
    keys = all_keys(options)
413

414
    _, stdout = gpg(options, "receive-keys", [key for _, key in keys.values() if key])
415
    return stdout.read().decode("utf-8")
Daniel STAN's avatar
init  
Daniel STAN committed
416

417 418 419 420 421
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"""
422
    # Il faut que la clé soit dans le réseau de confiance…
423 424 425
    meaning, trustvalue = GPG_TRUSTLEVELS[key[u"trustletter"]]
    if not trustvalue:
        return u"La confiance en la clé est : %s" % (meaning,)
426
    # …et qu'on puisse chiffrer avec…
427 428 429 430 431 432 433 434 435 436 437 438
    if u"e" in key[u"capabilities"]:
        # …soit directement…
        return u""
    # …soit avec une de ses subkeys
    esubkeys = [sub for sub in key[u"subkeys"] if u"e" in sub[u"capabilities"]]
    if len(esubkeys) == 0:
        return u"La clé principale de permet pas de chiffrer et auncune sous-clé de chiffrement."
    if any([GPG_TRUSTLEVELS[sub[u"trustletter"]][1] for sub in esubkeys]):
        return u""
    else:
        return u"Aucune sous clé de chiffrement n'est de confiance et non expirée."

439
def check_keys(options, recipients=None, quiet=False):
440 441
    """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).
442

443 444
        * Si ``recipients`` est fourni, vérifie seulement ces recipients.
          Renvoie la liste de ceux qu'on n'a pas droppés.
445 446
         * Si ``options.force=False``, demandera confirmation pour dropper un recipient dont la clé est invalide.
         * Sinon, et si ``options.drop_invalid=True``, droppe les recipients automatiquement.
447 448 449
        * Si rien n'est fourni, vérifie toutes les clés et renvoie juste un booléen disant si tout va bien.
       """
    trusted_recipients = []
450
    keys = all_keys(options)
451
    if recipients is None:
452
        speak = options.verbose and not options.quiet
453
    else:
454
        speak = False
455
        keys = {u : val for (u, val) in keys.iteritems() if u in recipients}
456
    if speak:
457
        print("M : le mail correspond à un uid du fingerprint\nC : confiance OK (inclut la vérification de non expiration).\n")
458
    _, gpgout = gpg(options, 'list-keys')
459
    localring = parse_keys(gpgout)
460 461 462
    for (recipient, (mail, fpr)) in keys.iteritems():
        failed = u""
        if not fpr is None:
463
            if speak:
464
                print((u"Checking %s… " % (mail)).encode("utf-8"), end="")
465
            key = localring.get(fpr, None)
466
            # On vérifie qu'on possède la clé…
467
            if not key is None:
468
                # …qu'elle correspond au mail…
469
                if any([u"<%s>" % (mail,) in u["uid"] for u in key["uids"]]):
470
                    if speak:
471
                        print("M ", end="")
472 473 474
                    # … et qu'on peut raisonnablement chiffrer pour lui
                    failed = _check_encryptable(key)
                    if not failed and speak:
475
                        print("C ", end="")
476
                else:
477
                    failed = u"!! Le fingerprint et le mail ne correspondent pas !"
478
            else:
479
                failed = u"Pas (ou trop) de clé avec ce fingerprint."
480
            if speak:
481
                print("")
482
            if failed:
483
                if not options.quiet:
484 485 486
                    print((u"--> Fail on %s:%s\n--> %s" % (mail, fpr, failed)).encode("utf-8"))
                if not recipients is None:
                    # On cherche à savoir si on droppe ce recipient
487
                    message = u"Abandonner le chiffrement pour cette clé ? (Si vous la conservez, il est posible que gpg crashe)"
488
                    if confirm(options, message, ('drop', fpr, mail)):
489 490 491
                        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
492
                    else:
493
                        drop = False # Là, on droppe pas
494 495
                    if not drop:
                        trusted_recipients.append(recipient)
496 497 498
                    else:
                        if not options.quiet:
                            print(u"Droppe la clé %s:%s" % (fpr, recipient))
499 500 501 502 503 504
            else:
                trusted_recipients.append(recipient)
    if recipients is None:
        return set(keys.keys()).issubset(trusted_recipients)
    else:
        return trusted_recipients
Daniel STAN's avatar
init  
Daniel STAN committed
505

506
def get_recipients_of_roles(options, roles):
Vincent Le gallic's avatar
Vincent Le gallic committed
507
    """Renvoie les destinataires d'une liste de rôles"""
508
    recipients = set()
509
    allroles = all_roles(options)
510
    for role in roles:
511 512
        if role == u"whoami":
            continue
513 514 515 516
        for recipient in allroles[role]:
            recipients.add(recipient)
    return recipients

517
def get_dest_of_roles(options, roles):
Vincent Le gallic's avatar
Vincent Le gallic committed
518
    """Renvoie la liste des "username : mail (fingerprint)" """
519
    allkeys = all_keys(options)
520
    return [u"%s : %s (%s)" % (rec, allkeys[rec][0], allkeys[rec][1])
521
               for rec in get_recipients_of_roles(options, roles) if allkeys[rec][1]]
522

523
def encrypt(options, roles, contents):
524
    """Chiffre le contenu pour les roles donnés"""
525

526 527 528
    allkeys = all_keys(options)
    recipients = get_recipients_of_roles(options, roles)
    recipients = check_keys(options, recipients=recipients, quiet=True)
529
    fpr_recipients = []
Daniel STAN's avatar
init  
Daniel STAN committed
530
    for recipient in recipients:
531 532 533 534
        fpr = allkeys[recipient][1]
        if fpr:
            fpr_recipients.append("-r")
            fpr_recipients.append(fpr)
535

536
    stdin, stdout = gpg(options, "encrypt", fpr_recipients)
537
    stdin.write(contents.encode("utf-8"))
Daniel STAN's avatar
init  
Daniel STAN committed
538
    stdin.close()
539
    out = stdout.read().decode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
540
    if out == '':
541
        return [False, u"Échec de chiffrement"]
Daniel STAN's avatar
Daniel STAN committed
542
    else:
543
        return [True, out]
Daniel STAN's avatar
init  
Daniel STAN committed
544

545
def decrypt(options, contents):
Daniel STAN's avatar
init  
Daniel STAN committed
546
    """Déchiffre le contenu"""
547
    stdin, stdout = gpg(options, "decrypt")
548 549 550
    if type(contents) != unicode: # Kludge (broken db ?)
        print("Eau dans le gaz (decrypt)" + repr(contents))
        contents = contents[-1]
551
    stdin.write(contents.encode("utf-8"))
Daniel STAN's avatar
init  
Daniel STAN committed
552
    stdin.close()
553
    return stdout.read().decode("utf-8")
Daniel STAN's avatar
init  
Daniel STAN committed
554

555
def put_password(options, roles, contents):
Daniel STAN's avatar
init  
Daniel STAN committed
556
    """Dépose le mot de passe après l'avoir chiffré pour les
557 558
    destinataires dans ``roles``."""
    success, enc_pwd_or_error = encrypt(options, roles, contents)
559 560
    if success:
        enc_pwd = enc_pwd_or_error
561
        return put_files(options, [{'filename' : options.fname, 'roles' : roles, 'contents' : enc_pwd}])[0]
Daniel STAN's avatar
Daniel STAN committed
562
    else:
563 564
        error = enc_pwd_or_error
        return [False, error]
Daniel STAN's avatar
init  
Daniel STAN committed
565

Vincent Le gallic's avatar
Vincent Le gallic committed
566
######
Daniel STAN's avatar
Daniel STAN committed
567 568
## Interface

569 570 571 572 573 574 575
NEED_FILENAME = []

def need_filename(f):
    """Décorateur qui ajoutera la fonction à la liste des fonctions qui attendent un filename."""
    NEED_FILENAME.append(f)
    return f

576
def editor(texte, annotations=u""):
577 578 579
    """ Lance $EDITOR sur texte.
    Renvoie le nouveau texte si des modifications ont été apportées, ou None
    """
580

581 582 583
    # Avoid syntax hilight with ".txt". Would be nice to have some colorscheme
    # for annotations ...
    f = tempfile.NamedTemporaryFile(suffix='.txt')
Daniel STAN's avatar
Daniel STAN committed
584
    atexit.register(f.close)
585 586
    if annotations:
        annotations = "# " + annotations.replace("\n", "\n# ")
Daniel STAN's avatar
Daniel STAN committed
587
        # Usually, there is already an ending newline in a text document
588
        if texte and texte[-1] != '\n':
Daniel STAN's avatar
Daniel STAN committed
589 590 591 592
            annotations = '\n' + annotations
        else:
            annotations += '\n'
    f.write((texte + annotations).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
593
    f.flush()
Daniel STAN's avatar
Daniel STAN committed
594
    proc = subprocess.Popen([os.getenv('EDITOR', '/usr/bin/editor'), f.name])
Vincent Le gallic's avatar
Vincent Le gallic committed
595
    os.waitpid(proc.pid, 0)
Daniel STAN's avatar
Daniel STAN committed
596
    f.seek(0)
Daniel STAN's avatar
Daniel STAN committed
597
    ntexte = f.read().decode("utf-8", errors='ignore')
Daniel STAN's avatar
Daniel STAN committed
598
    f.close()
599
    ntexte = u'\n'.join(filter(lambda l: not l.startswith('#'), ntexte.split('\n')))
600
    return ntexte
Daniel STAN's avatar
Daniel STAN committed
601

602
def show_files(options):
Vincent Le gallic's avatar
Vincent Le gallic committed
603
    """Affiche la liste des fichiers disponibles sur le serveur distant"""
Vincent Le gallic's avatar
Vincent Le gallic committed
604
    print(u"Liste des fichiers disponibles :".encode("utf-8"))
605 606
    my_roles, _ = get_my_roles(options)
    files = all_files(options)
607 608 609 610
    keys = files.keys()
    keys.sort()
    for fname in keys:
        froles = files[fname]
Daniel STAN's avatar
Daniel STAN committed
611
        access = set(my_roles).intersection(froles) != set([])
Vincent Le gallic's avatar
Vincent Le gallic committed
612 613
        print((u" %s %s (%s)" % ((access and '+' or '-'), fname, ", ".join(froles))).encode("utf-8"))
    print((u"""--Mes roles: %s""" % (", ".join(my_roles),)).encode("utf-8"))
614

615 616 617 618 619 620 621 622 623 624
def restore_files(options):
    """Restore les fichiers corrompues sur le serveur distant"""
    print(u"Fichier corrompus :".encode("utf-8"))
    files = restore_all_files(options)
    keys = files.keys()
    keys.sort()
    for fname in keys:
        print((u" %s (%s)" % ( fname, files[fname])).encode("utf-8"))


625
def show_roles(options):
Vincent Le gallic's avatar
Vincent Le gallic committed
626
    """Affiche la liste des roles existants"""
Vincent Le gallic's avatar
Vincent Le gallic committed
627
    print(u"Liste des roles disponibles".encode("utf-8"))
628 629
    allroles =  all_roles(options)
    for (role, usernames) in allroles.iteritems():
630 631
        if role == u"whoami":
            continue
Vincent Le gallic's avatar
Vincent Le gallic committed
632
        if not role.endswith('-w'):
633
            print((u" * %s : %s" % (role, ", ".join(usernames))).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
634

635
def show_servers(options):
Vincent Le gallic's avatar
Vincent Le gallic committed
636
    """Affiche la liste des serveurs disponibles"""
Vincent Le gallic's avatar
Vincent Le gallic committed
637
    print(u"Liste des serveurs disponibles".encode("utf-8"))
638
    for server in config.servers.keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
639
        print((u" * " + server).encode("utf-8"))
640

641
def saveclipboard(restore=False, old_clipboard=None):
Vincent Le gallic's avatar
Vincent Le gallic committed
642
    """Enregistre le contenu du presse-papier. Le rétablit si ``restore=True``"""
643 644 645
    if restore and old_clipboard == None:
        return
    act = '-in' if restore else '-out'
Vincent Le gallic's avatar
Vincent Le gallic committed
646 647
    proc = subprocess.Popen(['xclip', act, '-selection', 'clipboard'],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr)
648 649 650
    if not restore:
        old_clipboard = proc.stdout.read()
    else:
Daniel STAN's avatar
Daniel STAN committed
651 652 653 654 655 656 657 658 659 660 661 662
        if True:
            print(u"Password is in clipboard.".encode('utf-8'))
            try:
                for i in range(SHOW_TIMEOUT):
                    print(u"Will disappear in %d" % (SHOW_TIMEOUT - i))
                    time.sleep(1)
                    sys.stdout.write("\033[F") # Cursor up one line
                    sys.stdout.write("\033[K") # Clear to the end of line
            except KeyboardInterrupt:
                pass
        else:
            raw_input(u"Appuyez sur Entrée pour récupérer le contenu précédent du presse papier.".encode("utf-8"))
663 664 665
        proc.stdin.write(old_clipboard)
    proc.stdin.close()
    proc.stdout.close()
666
    return old_clipboard
667

Daniel STAN's avatar
Daniel STAN committed
668
def clipboard(texte):
Vincent Le gallic's avatar
Vincent Le gallic committed
669
    """Place ``texte`` dans le presse-papier en mémorisant l'ancien contenu."""
670
    old_clipboard = saveclipboard()
Vincent Le gallic's avatar
Vincent Le gallic committed
671 672
    proc =subprocess.Popen(['xclip', '-selection', 'clipboard'],\
        stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr)
673
    proc.stdin.write(texte.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
674
    proc.stdin.close()
675
    return old_clipboard
Daniel STAN's avatar
Daniel STAN committed
676

677 678
@need_filename
def show_file(options):
Vincent Le gallic's avatar
Vincent Le gallic committed
679
    """Affiche le contenu d'un fichier"""
680 681
    fname = options.fname
    gotit, value = get_files(options, [fname])[0]
682
    if not gotit:
683 684
        if not options.quiet:
            print(value.encode("utf-8")) # value contient le message d'erreur
Daniel STAN's avatar
Daniel STAN committed
685
        return
686
    passfile = value
687
    (sin, sout) = gpg(options, 'decrypt')
Daniel STAN's avatar
Daniel STAN committed
688 689 690
    content = passfile['contents']
    
    # Kludge (broken db ?)
691 692 693
    if type(content) == list:
        print("Eau dans le gaz")
        content = content[-1]
Daniel STAN's avatar
Daniel STAN committed
694 695

    # Déchiffre le contenu
696
    sin.write(content.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
697
    sin.close()
698
    texte = sout.read().decode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
699 700 701 702 703 704 705 706

    # Est-ce une clé ssh ?
    is_key = texte.startswith('-----BEGIN RSA PRIVATE KEY-----')
    # Est-ce que le mot de passe a été caché ? (si non, on utilisera less)
    is_hidden = is_key
    # Texte avec mdp caché
    filtered = u""
    # Ancien contenu du press papier
Daniel STAN's avatar
Daniel STAN committed
707
    old_clipboard = None
Daniel STAN's avatar
Daniel STAN committed
708 709 710

    # Essaie de planquer le mot de passe
    for line in texte.split('\n'):
Daniel STAN's avatar
Daniel STAN committed
711 712 713
        catchPass = None
        # On essaie de trouver le pass pour le cacher dans le clipboard
        # si ce n'est déjà fait et si c'est voulu
Daniel STAN's avatar
Daniel STAN committed
714
        if not is_hidden and options.clipboard:
Daniel STAN's avatar
Daniel STAN committed
715 716
            catchPass = pass_regexp.match(line)
        if catchPass != None:
Daniel STAN's avatar
Daniel STAN committed
717 718
            is_hidden = True
            # On met le mdp dans le clipboard en mémorisant son ancien contenu
719 720 721
            old_clipboard = clipboard(catchPass.group(1))
            # Et donc on override l'affichage
            line = u"[Le mot de passe a été mis dans le presse papier]"
Daniel STAN's avatar
Daniel STAN committed
722 723 724 725 726 727 728 729 730 731 732 733 734 735 736
        filtered += line + '\n'

    if is_key:
        filtered = u"La clé a été mise dans l'agent ssh"
    shown = u"Fichier %s:\n\n%s-----\nVisible par: %s\n" % (fname, filtered, ','.join(passfile['roles']))

    if is_key:
        with tempfile.NamedTemporaryFile(suffix='') as key_file:
            # Génère la clé publique correspondante
            key_file.write(texte.encode('utf-8'))
            key_file.flush()
            pub = subprocess.check_output(['ssh-keygen', '-y', '-f', key_file.name])
            # Charge en mémoire
            subprocess.check_call(['ssh-add', key_file.name])

737 738 739 740 741 742 743
        # On attend (hors tmpfile)
        print(shown.encode('utf-8'))
        raw_input()
        with tempfile.NamedTemporaryFile(suffix='') as pub_file:
            # On met la clé publique en fichier pour suppression
            pub_file.write(pub)
            pub_file.flush()
Daniel STAN's avatar
Daniel STAN committed
744

745
            subprocess.check_call(['ssh-add', '-d', pub_file.name])
Daniel STAN's avatar
Daniel STAN committed
746 747 748 749 750 751 752 753 754
    else:
        # Le binaire à utiliser
        showbin = "cat" if is_hidden else "less"
        proc = subprocess.Popen([showbin], stdin=subprocess.PIPE)
        out = proc.stdin
        out.write(shown.encode("utf-8"))
        out.close()
        os.waitpid(proc.pid, 0)

Daniel STAN's avatar
Daniel STAN committed
755
    # Repope ancien pass
756
    if old_clipboard is not None:
757
        saveclipboard(restore=True, old_clipboard=old_clipboard)
758

759 760
@need_filename
def edit_file(options):
Vincent Le gallic's avatar
Vincent Le gallic committed
761
    """Modifie/Crée un fichier"""
762 763
    fname = options.fname
    gotit, value = get_files(options, [fname])[0]
Daniel STAN's avatar
Daniel STAN committed
764
    nfile = False
765
    annotations = u""
766 767 768 769 770

    my_roles, _ = get_my_roles(options)
    new_roles = options.roles

    # Cas du nouveau fichier
771
    if not gotit and not u"pas les droits" in value:
Daniel STAN's avatar
Daniel STAN committed
772
        nfile = True
773 774 775
        if not options.quiet:
            print(u"Fichier introuvable".encode("utf-8"))
        if not confirm(options, u"Créer fichier ?"):
Daniel STAN's avatar
Daniel STAN committed
776
            return
777
        annotations += u"""Ceci est un fichier initial contenant un mot de passe
Vincent Le gallic's avatar
Vincent Le gallic committed
778 779
aléatoire, pensez à rajouter une ligne "login: ${login}"
Enregistrez le fichier vide pour annuler.\n"""
780
        texte = u"pass: %s\n" % gen_password()
781 782 783 784

        if new_roles is None:
            new_roles = parse_roles(options, cast=True)
        passfile = {'roles' : new_roles}
785
    elif not gotit:
786 787
        if not options.quiet:
            print(value.encode("utf-8")) # value contient le message d'erreur
788
        return
Daniel STAN's avatar
Daniel STAN committed
789
    else:
790
        passfile = value
791
        (sin, sout) = gpg(options, 'decrypt')
Daniel STAN's avatar
Daniel STAN committed
792 793 794 795 796 797
        contents = passfile['contents']
        # <ducktape> (waddle waddle)
        if isinstance(contents, list):
            contents = contents[-1]
        # </ducktape>
        sin.write(contents.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
798
        sin.close()
799
        texte = sout.read().decode("utf-8")
800 801 802 803 804 805 806 807 808 809 810 811 812
        if new_roles is None:
            new_roles = passfile['roles']

    # On vérifie qu'on a le droit actuellement (plutôt que de se faire jeter
    # plus tard)
    if not any(r + '-w' in my_roles for r in passfile['roles']):
        if not options.quiet:
            print(u"Aucun rôle en écriture pour ce fichier ! Abandon.".encode("utf-8"))
        return

    # On peut vouloir chiffrer un fichier sans avoir la possibilité de le lire
    # dans le futur, mais dans ce cas on préfère demander confirmation
    if not any(r + '-w' in my_roles for r in new_roles):
Daniel STAN's avatar
Daniel STAN committed
813
        message = u"""Vous vous apprêtez à perdre vos droits en écriture""" + \
814 815
            """(ROLES ne contient rien parmi : %s) sur ce fichier, continuer ?"""
        message = message % (", ".join(r[:-2] for r in my_roles if '-w' in r),)
816
        if not confirm(options, message):
817
            return
818

Vincent Le gallic's avatar
Vincent Le gallic committed
819 820
    annotations += u"""Ce fichier sera chiffré pour les rôles suivants :\n%s\n
C'est-à-dire pour les utilisateurs suivants :\n%s""" % (
Daniel STAN's avatar
Daniel STAN committed
821
           ', '.join(new_roles),
822
           '\n'.join(' %s' % rec for rec in get_dest_of_roles(options, new_roles))
823
        )
824

825
    ntexte = editor(texte, annotations)
826

827 828 829
    if ((not nfile and ntexte in [u'', 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,
        or (nfile and ntexte == u'')):                 # ou alors on a créé un fichier vide.
830 831 832 833 834
        message = u"Pas de modification à enregistrer.\n"
        message += u"Si ce n'est pas un nouveau fichier, il a été vidé ou n'a pas été modifié (même pas ses rôles).\n"
        message += u"Si c'est un nouveau fichier, vous avez tenté de le créer vide."
        if not options.quiet:
            print(message.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
835
    else:
Daniel STAN's avatar
Daniel STAN committed
836
        ntexte = texte if ntexte == None else ntexte
837
        success, message = put_password(options, new_roles, ntexte)
838
        print(message.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
839

840 841 842 843 844 845 846
_remember_dict = {}
def confirm(options, text, remember_key=None):
    """Demande confirmation, sauf si on est mode ``--force``.
    Si ``remember_key`` est fourni, il doit correspondre à un objet hashable
    qui permettra de ne pas poser deux fois les mêmes questions.
    """
    global _remember_dict
847 848
    if options.force:
        return True
849 850
    if remember_key in _remember_dict:
        return _remember_dict[remember_key]
Daniel STAN's avatar
Daniel STAN committed
851
    while True:
852
        out = raw_input((text + u' (o/n)').encode("utf-8")).lower()
Daniel STAN's avatar
Daniel STAN committed
853
        if out == 'o':
854 855
            res = True
            break
Daniel STAN's avatar
Daniel STAN committed
856
        elif out == 'n':
857 858 859 860 861 862
            res = False
            break
    # Remember the answer
    if remember_key is not None:
        _remember_dict[remember_key] = res
    return res
Daniel STAN's avatar
Daniel STAN committed
863

864 865
@need_filename
def remove_file(options):
Vincent Le gallic's avatar
Vincent Le gallic committed
866
    """Supprime un fichier"""