client.py 29.8 KB
Newer Older
Daniel STAN's avatar
init  
Daniel STAN committed
1 2
#!/usr/bin/env python
# -*- 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
26 27

# Import de la config
28
try:
29 30 31
    # 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,)))
32 33
    import clientconfig as config
except ImportError:
34 35
    if sys.stderr.isatty() and not any([opt in sys.argv for opt in ["-q", "--quiet"]]):
        sys.stderr.write(u"Va lire le fichier README.\n".encode("utf-8"))
36
    sys.exit(1)
Daniel STAN's avatar
init  
Daniel STAN committed
37

Vincent Le gallic's avatar
Vincent Le gallic committed
38 39
#: pattern utilisé pour détecter la ligne contenant le mot de passe dans les fichiers
PASS = re.compile('[\t ]*pass(?:word)?[\t ]*:[\t ]*(.*)\r?\n?$',
Daniel STAN's avatar
Daniel STAN committed
40 41
        flags=re.IGNORECASE)

42 43 44 45 46 47 48 49 50 51 52 53
## Conf qu'il faudrait éliminer en passant ``parsed`` aux fonctions
#: Mode verbeux
VERB = False
#: Par défaut, place-t-on le mdp dans le presse-papier ?
CLIPBOARD = bool(os.getenv('DISPLAY')) and os.path.exists('/usr/bin/xclip')
#: Mode «ne pas demander confirmation»
FORCED = False
#: Droits à définir sur le fichier en édition
NEWROLES = None
#: Serveur à interroger (peuplée à l'exécution)
SERVER = None

Daniel STAN's avatar
init  
Daniel STAN committed
54
## GPG Definitions
55
#: Path du binaire gpg
Daniel STAN's avatar
init  
Daniel STAN committed
56
GPG = '/usr/bin/gpg'
57 58

#: Paramètres à fournir à gpg en fonction de l'action désirée
Daniel STAN's avatar
init  
Daniel STAN committed
59
GPG_ARGS = {
Vincent Le gallic's avatar
Vincent Le gallic committed
60 61 62
    'decrypt' : ['-d'],
    'encrypt' : ['--armor', '-es'],
    'receive-keys' : ['--recv-keys'],
63 64
    '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
65
    }
66 67

#: Mapping (lettre de trustlevel) -> (signification, faut-il faire confiance à la clé)
68
GPG_TRUSTLEVELS = {
69 70 71
                    u"-" : (u"inconnue (pas de valeur assignée)", False),
                    u"o" : (u"inconnue (nouvelle clé)", False),
                    u"i" : (u"invalide (self-signature manquante ?)", False),
72 73 74 75 76 77
                    u"n" : (u"nulle", False),
                    u"m" : (u"marginale", True),
                    u"f" : (u"entière", True),
                    u"u" : (u"ultime", True),
                    u"r" : (u"révoquée", False),
                    u"e" : (u"expirée", False),
78
                    u"q" : (u"non définie", False),
79
                  }
Daniel STAN's avatar
Daniel STAN committed
80

81
def gpg(command, args=None, verbose=False):
Daniel STAN's avatar
init  
Daniel STAN committed
82
    """Lance gpg pour la commande donnée avec les arguments
83
    donnés. Renvoie son entrée standard et sa sortie standard."""
Daniel STAN's avatar
init  
Daniel STAN committed
84 85 86 87
    full_command = [GPG]
    full_command.extend(GPG_ARGS[command])
    if args:
        full_command.extend(args)
88
    if verbose or VERB:
89
        stderr = sys.stderr
Daniel STAN's avatar
Daniel STAN committed
90
    else:
91
        stderr = subprocess.PIPE
Daniel STAN's avatar
Daniel STAN committed
92
        full_command.extend(['--debug-level=1'])
Daniel STAN's avatar
init  
Daniel STAN committed
93 94 95
    proc = subprocess.Popen(full_command,
                            stdin = subprocess.PIPE,
                            stdout = subprocess.PIPE,
Daniel STAN's avatar
Daniel STAN committed
96
                            stderr = stderr,
Daniel STAN's avatar
init  
Daniel STAN committed
97
                            close_fds = True)
98
    if not (verbose or VERB):
Daniel STAN's avatar
Daniel STAN committed
99
        proc.stderr.close()
Daniel STAN's avatar
init  
Daniel STAN committed
100 101
    return proc.stdin, proc.stdout

102 103 104 105 106 107 108 109 110 111 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 182 183 184 185 186 187 188 189 190 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
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»
        line = line.decode("utf-8")
        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
226 227 228 229 230 231 232 233

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

    def __call__(self):
Vincent Le gallic's avatar
Vincent Le gallic committed
234
        if self.val == None:
235 236 237
            self.val = self.f()
        return self.val

Vincent Le gallic's avatar
Vincent Le gallic committed
238

Daniel STAN's avatar
init  
Daniel STAN committed
239 240 241 242 243 244
######
## Remote commands

def ssh(command, arg = None):
    """Lance ssh avec les arguments donnés. Renvoie son entrée
    standard et sa sortie standard."""
245
    full_command = list(SERVER['server_cmd'])
Daniel STAN's avatar
init  
Daniel STAN committed
246 247 248 249 250 251 252 253 254 255 256 257 258 259 260
    full_command.append(command)
    if arg:
        full_command.append(arg)
    proc = subprocess.Popen(full_command,
                            stdin = subprocess.PIPE,
                            stdout = subprocess.PIPE,
                            stderr = sys.stderr,
                            close_fds = True)
    return proc.stdin, proc.stdout

def remote_command(command, arg = None, stdin_contents = None):
    """Exécute la commande distante, et retourne la sortie de cette
    commande"""
    
    sshin, sshout = ssh(command, arg)
261
    if not stdin_contents is None:
Daniel STAN's avatar
init  
Daniel STAN committed
262 263
        sshin.write(json.dumps(stdin_contents))
        sshin.close()
264 265
    raw_out = sshout.read()
    return json.loads(raw_out)
Daniel STAN's avatar
init  
Daniel STAN committed
266

267
@simple_memoize
Daniel STAN's avatar
init  
Daniel STAN committed
268 269 270 271
def all_keys():
    """Récupère les clés du serveur distant"""
    return remote_command("listkeys")

272
@simple_memoize
Daniel STAN's avatar
init  
Daniel STAN committed
273 274 275 276
def all_roles():
    """Récupère les roles du serveur distant"""
    return remote_command("listroles")

277
@simple_memoize
Daniel STAN's avatar
init  
Daniel STAN committed
278 279 280 281
def all_files():
    """Récupère les fichiers du serveur distant"""
    return remote_command("listfiles")

282 283 284
def get_files(filenames):
    """Récupère le contenu des fichiers distants"""
    return remote_command("getfiles", stdin_contents=filenames)
Daniel STAN's avatar
init  
Daniel STAN committed
285

286 287 288
def put_files(files):
    """Dépose les fichiers sur le serveur distant"""
    return remote_command("putfiles", stdin_contents=files)
Vincent Le gallic's avatar
Vincent Le gallic committed
289

Daniel STAN's avatar
init  
Daniel STAN committed
290 291 292 293
def rm_file(filename):
    """Supprime le fichier sur le serveur distant"""
    return remote_command("rmfile", filename)

294
@simple_memoize
Daniel STAN's avatar
Daniel STAN committed
295
def get_my_roles():
Vincent Le gallic's avatar
Vincent Le gallic committed
296
    """Retourne la liste des rôles de l'utilisateur"""
Daniel STAN's avatar
Daniel STAN committed
297
    allr = all_roles()
Vincent Le gallic's avatar
Vincent Le gallic committed
298
    return filter(lambda role: SERVER['user'] in allr[role], allr.keys())
Daniel STAN's avatar
Daniel STAN committed
299

300
def gen_password():
Vincent Le gallic's avatar
Vincent Le gallic committed
301
    """Génère un mot de passe aléatoire"""
302 303 304
    random.seed(datetime.datetime.now().microsecond)
    chars = string.letters + string.digits + '/=+*'
    length = 15
305
    return u''.join([random.choice(chars) for _ in xrange(length)])
306

Daniel STAN's avatar
init  
Daniel STAN committed
307 308 309 310 311
######
## Local commands

def update_keys():
    """Met à jour les clés existantes"""
Vincent Le gallic's avatar
Vincent Le gallic committed
312
    
Daniel STAN's avatar
init  
Daniel STAN committed
313
    keys = all_keys()
Vincent Le gallic's avatar
Vincent Le gallic committed
314
    
Daniel STAN's avatar
init  
Daniel STAN committed
315
    _, stdout = gpg("receive-keys", [key for _, key in keys.values() if key])
316
    return stdout.read().decode("utf-8")
Daniel STAN's avatar
init  
Daniel STAN committed
317

318 319 320 321 322 323 324 325 326 327 328 329 330
def check_keys(recipients=None, interactive=False, drop_invalid=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).
       
        * Si ``recipients`` est fourni, vérifie seulement ces recipients.
          Renvoie la liste de ceux qu'on n'a pas droppés.
         * Si ``interactive=True``, demandera confirmation pour dropper un recipient dont la clé est invalide.
         * Sinon, et si ``drop_invalid=True``, droppe les recipients automatiquement.
        * Si rien n'est fourni, vérifie toutes les clés et renvoie juste un booléen disant si tout va bien.
       """
    if QUIET:
        interactive = False
    trusted_recipients = []
331
    keys = all_keys()
332 333 334 335 336 337 338
    if recipients is None:
        SPEAK = VERB
    else:
        SPEAK = False
        keys = {u : val for (u, val) in keys.iteritems() if u in recipients}
    if SPEAK:
        print("M : le mail correspond à un uid du fingerprint\nC : confiance OK (inclut la vérification de non expiration).\n")
339 340
    _, gpgout = gpg('list-keys')
    localring = parse_keys(gpgout)
341 342 343 344
    for (recipient, (mail, fpr)) in keys.iteritems():
        failed = u""
        if not fpr is None:
            if SPEAK:
345
                print((u"Checking %s… " % (mail)).encode("utf-8"), end="")
346
            key = localring.get(fpr, None)
347
            # On vérifie qu'on possède la clé…
348
            if not key is None:
349
                # …qu'elle correspond au mail…
350
                if any([u"<%s>" % (mail,) in u["uid"] for u in key["uids"]]):
351
                    if SPEAK:
352
                        print("M ", end="")
353
                    meaning, trustvalue = GPG_TRUSTLEVELS[key["trustletter"]]
354 355
                    # … et qu'on lui fait confiance
                    if not trustvalue:
356 357
                        failed = u"La confiance en la clé est : %s" % (meaning,)
                    elif SPEAK:
358
                        print("C ", end="")
359
                else:
360
                    failed = u"!! Le fingerprint et le mail ne correspondent pas !"
361
            else:
362 363
                failed = u"Pas (ou trop) de clé avec ce fingerprint."
            if SPEAK:
364
                print("")
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383
            if failed:
                if not QUIET:
                    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
                    drop = True # par défaut, on le drope
                    if interactive:
                        if not confirm(u"Abandonner le chiffrement pour cette clé ? (Si vous la conservez, il est posible que gpg crashe)"):
                            drop = False # sauf si on a répondu non à "abandonner ?"
                    elif not drop_invalid:
                        drop = False # ou bien si drop_invalid ne nous autorise pas à le dropper silencieusement
                    if not drop:
                        trusted_recipients.append(recipient)
            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
384

385 386 387 388 389 390 391 392 393 394
def get_recipients_of_roles(roles):
    """Renvoie les destinataires d'un rôle"""
    recipients = set()
    allroles = all_roles()
    for role in roles:
        for recipient in allroles[role]:
            recipients.add(recipient)
    return recipients

def get_dest_of_roles(roles):
Vincent Le gallic's avatar
Vincent Le gallic committed
395
    """Renvoie la liste des "username : mail (fingerprint)" """
396
    allkeys = all_keys()
397
    return [u"%s : %s (%s)" % (rec, allkeys[rec][0], allkeys[rec][1])
398
               for rec in get_recipients_of_roles(roles) if allkeys[rec][1]]
399

400 401 402
def encrypt(roles, contents, interactive_trust=True, drop_invalid=False):
    """Chiffre le contenu pour les roles donnés"""
    
Daniel STAN's avatar
init  
Daniel STAN committed
403
    allkeys = all_keys()
404
    recipients = get_recipients_of_roles(roles)
405
    recipients = check_keys(recipients, interactive=interactive_trust, drop_invalid=drop_invalid)
406
    fpr_recipients = []
Daniel STAN's avatar
init  
Daniel STAN committed
407
    for recipient in recipients:
408 409 410 411
        fpr = allkeys[recipient][1]
        if fpr:
            fpr_recipients.append("-r")
            fpr_recipients.append(fpr)
Vincent Le gallic's avatar
Vincent Le gallic committed
412
    
413
    stdin, stdout = gpg("encrypt", fpr_recipients)
414
    stdin.write(contents.encode("utf-8"))
Daniel STAN's avatar
init  
Daniel STAN committed
415
    stdin.close()
416
    out = stdout.read().decode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
417
    if out == '':
418
        return [False, u"Échec de chiffrement"]
Daniel STAN's avatar
Daniel STAN committed
419
    else:
420
        return [True, out]
Daniel STAN's avatar
init  
Daniel STAN committed
421 422 423 424

def decrypt(contents):
    """Déchiffre le contenu"""
    stdin, stdout = gpg("decrypt")
425
    stdin.write(contents.encode("utf-8"))
Daniel STAN's avatar
init  
Daniel STAN committed
426
    stdin.close()
427
    return stdout.read().decode("utf-8")
Daniel STAN's avatar
init  
Daniel STAN committed
428

429
def put_password(name, roles, contents, interactive_trust=True, drop_invalid=False):
Daniel STAN's avatar
init  
Daniel STAN committed
430 431
    """Dépose le mot de passe après l'avoir chiffré pour les
    destinataires donnés"""
432
    success, enc_pwd_or_error = encrypt(roles, contents, interactive_trust, drop_invalid)
433 434
    if NEWROLES != None:
        roles = NEWROLES
Daniel STAN's avatar
Daniel STAN committed
435
        if VERB:
Vincent Le gallic's avatar
Vincent Le gallic committed
436
            print(u"Pas de nouveaux rôles".encode("utf-8"))
437 438
    if success:
        enc_pwd = enc_pwd_or_error
439
        return put_files([{'filename' : name, 'roles' : roles, 'contents' : enc_pwd}])[0]
Daniel STAN's avatar
Daniel STAN committed
440
    else:
441 442
        error = enc_pwd_or_error
        return [False, error]
Daniel STAN's avatar
init  
Daniel STAN committed
443 444 445

def get_password(name):
    """Récupère le mot de passe donné par name"""
446
    gotit, remotefile = get_files([name])[0]
447 448 449
    if gotit:
        remotefile = decrypt(remotefile['contents'])
    return [gotit, remotefile]
Daniel STAN's avatar
init  
Daniel STAN committed
450

Vincent Le gallic's avatar
Vincent Le gallic committed
451
######
Daniel STAN's avatar
Daniel STAN committed
452 453
## Interface

454
def editor(texte, annotations=u""):
455 456 457
    """ Lance $EDITOR sur texte.
    Renvoie le nouveau texte si des modifications ont été apportées, ou None
    """
Vincent Le gallic's avatar
Vincent Le gallic committed
458
    
459 460 461
    # 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
462
    atexit.register(f.close)
463 464 465
    if annotations:
        annotations = "# " + annotations.replace("\n", "\n# ")
    f.write((texte + "\n" + annotations).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
466
    f.flush()
Daniel STAN's avatar
Daniel STAN committed
467
    proc = subprocess.Popen([os.getenv('EDITOR', '/usr/bin/editor'), f.name])
Vincent Le gallic's avatar
Vincent Le gallic committed
468
    os.waitpid(proc.pid, 0)
Daniel STAN's avatar
Daniel STAN committed
469
    f.seek(0)
470
    ntexte = f.read().decode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
471
    f.close()
472
    ntexte = u'\n'.join(filter(lambda l: not l.startswith('#'), ntexte.split('\n')))
473
    return ntexte
Daniel STAN's avatar
Daniel STAN committed
474 475

def show_files():
Vincent Le gallic's avatar
Vincent Le gallic committed
476
    """Affiche la liste des fichiers disponibles sur le serveur distant"""
Vincent Le gallic's avatar
Vincent Le gallic committed
477
    print(u"Liste des fichiers disponibles :".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
478
    my_roles = get_my_roles()
479 480 481 482 483
    files = all_files()
    keys = files.keys()
    keys.sort()
    for fname in keys:
        froles = files[fname]
Daniel STAN's avatar
Daniel STAN committed
484
        access = set(my_roles).intersection(froles) != set([])
Vincent Le gallic's avatar
Vincent Le gallic committed
485 486
        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"))
487
    
Daniel STAN's avatar
Daniel STAN committed
488
def show_roles():
Vincent Le gallic's avatar
Vincent Le gallic committed
489
    """Affiche la liste des roles existants"""
Vincent Le gallic's avatar
Vincent Le gallic committed
490
    print(u"Liste des roles disponibles".encode("utf-8"))
491
    for (role, usernames) in all_roles().iteritems():
Vincent Le gallic's avatar
Vincent Le gallic committed
492
        if not role.endswith('-w'):
493
            print((u" * %s : %s" % (role, ", ".join(usernames))).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
494

495
def show_servers():
Vincent Le gallic's avatar
Vincent Le gallic committed
496
    """Affiche la liste des serveurs disponibles"""
Vincent Le gallic's avatar
Vincent Le gallic committed
497
    print(u"Liste des serveurs disponibles".encode("utf-8"))
498
    for server in config.servers.keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
499
        print((u" * " + server).encode("utf-8"))
500

501 502
old_clipboard = None
def saveclipboard(restore=False):
Vincent Le gallic's avatar
Vincent Le gallic committed
503
    """Enregistre le contenu du presse-papier. Le rétablit si ``restore=True``"""
504 505 506 507
    global old_clipboard
    if restore and old_clipboard == None:
        return
    act = '-in' if restore else '-out'
Vincent Le gallic's avatar
Vincent Le gallic committed
508 509
    proc = subprocess.Popen(['xclip', act, '-selection', 'clipboard'],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr)
510 511 512
    if not restore:
        old_clipboard = proc.stdout.read()
    else:
513
        raw_input(u"Appuyez sur Entrée pour récupérer le contenu précédent du presse papier.".encode("utf-8"))
514 515 516 517
        proc.stdin.write(old_clipboard)
    proc.stdin.close()
    proc.stdout.close()

Daniel STAN's avatar
Daniel STAN committed
518
def clipboard(texte):
Vincent Le gallic's avatar
Vincent Le gallic committed
519
    """Place ``texte`` dans le presse-papier en mémorisant l'ancien contenu."""
520
    saveclipboard()
Vincent Le gallic's avatar
Vincent Le gallic committed
521 522
    proc =subprocess.Popen(['xclip', '-selection', 'clipboard'],\
        stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr)
523
    proc.stdin.write(texte.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
524
    proc.stdin.close()
525
    return u"[Le mot de passe a été mis dans le presse papier]"
Daniel STAN's avatar
Daniel STAN committed
526 527 528


def show_file(fname):
Vincent Le gallic's avatar
Vincent Le gallic committed
529
    """Affiche le contenu d'un fichier"""
530
    gotit, value = get_files([fname])[0]
531 532
    if not gotit:
        print(value.encode("utf-8")) # value contient le message d'erreur
Daniel STAN's avatar
Daniel STAN committed
533
        return
Vincent Le gallic's avatar
Vincent Le gallic committed
534
    (sin, sout) = gpg('decrypt')
535
    sin.write(value['contents'].encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
536
    sin.close()
537 538
    texte = sout.read().decode("utf-8")
    ntexte = u""
Daniel STAN's avatar
Daniel STAN committed
539 540 541 542 543
    hidden = False  # Est-ce que le mot de passe a été caché ?
    lines = texte.split('\n')
    for line in lines:
        catchPass = PASS.match(line)
        if catchPass != None and CLIPBOARD:
544
            hidden = True
Daniel STAN's avatar
Daniel STAN committed
545 546 547
            line = clipboard(catchPass.group(1))
        ntexte += line + '\n'
    showbin = "cat" if hidden else "less"
548
    proc = subprocess.Popen([showbin], stdin=subprocess.PIPE)
Daniel STAN's avatar
Daniel STAN committed
549
    out = proc.stdin
550 551
    raw = u"Fichier %s:\n\n%s-----\nVisible par: %s\n" % (fname, ntexte, ','.join(value['roles']))
    out.write(raw.encode("utf-8"))
552
    out.close()
Daniel STAN's avatar
Daniel STAN committed
553
    os.waitpid(proc.pid, 0)
554

Daniel STAN's avatar
Daniel STAN committed
555
        
556
def edit_file(fname, interactive_trust=True, drop_invalid=False):
Vincent Le gallic's avatar
Vincent Le gallic committed
557
    """Modifie/Crée un fichier"""
558
    gotit, value = get_files([fname])[0]
Daniel STAN's avatar
Daniel STAN committed
559
    nfile = False
560
    annotations = u""
561
    if not gotit and not "pas les droits" in value:
Daniel STAN's avatar
Daniel STAN committed
562
        nfile = True
Vincent Le gallic's avatar
Vincent Le gallic committed
563
        print(u"Fichier introuvable".encode("utf-8"))
Vincent Le gallic's avatar
Vincent Le gallic committed
564
        if not confirm(u"Créer fichier ?"):
Daniel STAN's avatar
Daniel STAN committed
565
            return
566
        annotations += u"""Ceci est un fichier initial contenant un mot de passe
Vincent Le gallic's avatar
Vincent Le gallic committed
567 568
aléatoire, pensez à rajouter une ligne "login: ${login}"
Enregistrez le fichier vide pour annuler.\n"""
569
        texte = u"pass: %s\n" % gen_password()
570 571 572
        roles = get_my_roles()
        # Par défaut les roles d'un fichier sont ceux en écriture de son
        # créateur
573
        roles = [ r[:-2] for r in roles if r.endswith('-w') ]
574
        if roles == []:
Vincent Le gallic's avatar
Vincent Le gallic committed
575
            print(u"Vous ne possédez aucun rôle en écriture ! Abandon.".encode("utf-8"))
576
            return
Vincent Le gallic's avatar
Vincent Le gallic committed
577
        value = {'roles' : roles}
578 579 580
    elif not gotit:
        print(value.encode("utf-8")) # value contient le message d'erreur
        return
Daniel STAN's avatar
Daniel STAN committed
581
    else:
Vincent Le gallic's avatar
Vincent Le gallic committed
582
        (sin, sout) = gpg('decrypt')
583
        sin.write(value['contents'].encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
584
        sin.close()
585
        texte = sout.read().decode("utf-8")
586 587
    # On récupère les nouveaux roles si ils ont été précisés, sinon on garde les mêmes
    value['roles'] = NEWROLES or value['roles']
588
    
Vincent Le gallic's avatar
Vincent Le gallic committed
589 590
    annotations += u"""Ce fichier sera chiffré pour les rôles suivants :\n%s\n
C'est-à-dire pour les utilisateurs suivants :\n%s""" % (
591 592 593 594 595
           ', '.join(value['roles']),
           '\n'.join(' %s' % rec for rec in get_dest_of_roles(value['roles']))
        )
        
    ntexte = editor(texte, annotations)
596
    
597
    if ((not nfile and ntexte in [u'', texte] and NEWROLES == None) or # Fichier existant vidé ou inchangé
598
        (nfile and ntexte == u'')):                                    # Nouveau fichier créé vide
599
        print(u"Pas de modification effectuée".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
600
    else:
Daniel STAN's avatar
Daniel STAN committed
601
        ntexte = texte if ntexte == None else ntexte
602
        success, message = put_password(fname, value['roles'], ntexte, interactive_trust, drop_invalid)
603
        print(message.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
604 605

def confirm(text):
Vincent Le gallic's avatar
Vincent Le gallic committed
606
    """Demande confirmation, sauf si on est mode ``FORCED``"""
Daniel STAN's avatar
Daniel STAN committed
607 608
    if FORCED: return True
    while True:
609
        out = raw_input((text + u' (o/n)').encode("utf-8")).lower()
Daniel STAN's avatar
Daniel STAN committed
610 611 612 613 614 615
        if out == 'o':
            return True
        elif out == 'n':
            return False

def remove_file(fname):
Vincent Le gallic's avatar
Vincent Le gallic committed
616
    """Supprime un fichier"""
617
    if not confirm(u'Êtes-vous sûr de vouloir supprimer %s ?' % fname):
Daniel STAN's avatar
Daniel STAN committed
618
        return
619 620
    message = rm_file(fname)
    print(message.encode("utf-8"))
Vincent Le gallic's avatar
Vincent Le gallic committed
621

Daniel STAN's avatar
Daniel STAN committed
622 623

def my_check_keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
624
    """Vérifie les clés et affiche un message en fonction du résultat"""
Vincent Le gallic's avatar
Vincent Le gallic committed
625 626
    print(u"Vérification que les clés sont valides (uid correspondant au login) et de confiance.")
    print((check_keys() and u"Base de clés ok" or u"Erreurs dans la base").encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
627 628

def my_update_keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
629
    """Met à jour les clés existantes et affiche le résultat"""
Vincent Le gallic's avatar
Vincent Le gallic committed
630
    print(update_keys().encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
631

632
def recrypt_files(interactive_trust=False, drop_invalid=True):
Vincent Le gallic's avatar
Vincent Le gallic committed
633
    """Rechiffre les fichiers"""
634 635
    # Ici, la signification de NEWROLES est : on ne veut rechiffrer que les fichiers qui ont au moins un de ces roles
    rechiffre_roles = NEWROLES
Daniel STAN's avatar
Daniel STAN committed
636
    my_roles = get_my_roles()
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667
    my_roles_w = [r for r in my_roles if r.endswith("-w")]
    if rechiffre_roles == None:
        # Sans précisions, on prend tous les roles qu'on peut
        rechiffre_roles = my_roles
    # On ne conserve que les rôles en écriture
    rechiffre_roles = [ r[:-2] for r in rechiffre_roles if r.endswith('-w')]
    
    # La liste des fichiers
    allfiles = all_files()
    # On ne demande que les fichiers dans lesquels on peut écrire
    # et qui ont au moins un role dans ``roles``
    askfiles = [filename for (filename, fileroles) in allfiles.iteritems()
                         if set(fileroles).intersection(roles) != set()
                         and set(fileroles).intersection(my_roles_w) != set()]
    files = get_files(askfiles)
    # Au cas où on aurait échoué à récupérer ne serait-ce qu'un de ces fichiers,
    # on affiche le message d'erreur correspondant et on abandonne.
    for (success, message) in files:
        if not success:
            print(message.encode("utf-8"))
            return
    # On rechiffre
    to_put = [{'filename' : f['filename'],
               'roles' : f['roles'],
               'contents' : encrypt(f['roles'], decrypt(f['contents']))}
              for f in files]
    if to_put:
        print((u"Rechiffrement de %s" % (", ".join([f['filename'] for f in to_put]))).encode("utf-8"))
        results = put_files(to_put)
        # On affiche les messages de retour
        for i in range(len(results)):
668
            print(u"%s : %s" % (to_put[i]['filename'], results[i][1]))
669 670
    else:
        print(u"Aucun fichier n'a besoin d'être rechiffré".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
671 672

def parse_roles(strroles):
673 674
    """Interprête une liste de rôles fournie par l'utilisateur.
       Renvoie ``False`` si au moins un de ces rôles pose problème."""
Daniel STAN's avatar
Daniel STAN committed
675 676
    if strroles == None: return None
    roles = all_roles()
677
    my_roles = filter(lambda r: SERVER['user'] in roles[r], roles.keys())
678
    my_roles_w = [ r[:-2] for r in my_roles if r.endswith('-w') ]
Daniel STAN's avatar
Daniel STAN committed
679 680 681 682
    ret = set()
    writable = False
    for role in strroles.split(','):
        if role not in roles.keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
683
            print((u"Le rôle %s n'existe pas !" % role).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
684 685
            return False
        if role.endswith('-w'):
Vincent Le gallic's avatar
Vincent Le gallic committed
686
            print((u"Le rôle %s ne devrait pas être utilisé ! (utilisez %s)")
687
                   % (role, role[:-2])).encode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
688 689 690 691 692
            return False
        writable = writable or role in my_roles_w
        ret.add(role)
    
    if not FORCED and not writable:
693
        if not confirm(u"""Vous vous apprêtez à perdre vos droits d'écritures\
694
(ROLES ne contient pas %s) sur ce fichier, continuer ?""" %
Daniel STAN's avatar
Daniel STAN committed
695
            ", ".join(my_roles_w)):
Daniel STAN's avatar
Daniel STAN committed
696 697
            return False
    return list(ret)
Daniel STAN's avatar
Daniel STAN committed
698 699

if __name__ == "__main__":
Daniel STAN's avatar
Daniel STAN committed
700
    parser = argparse.ArgumentParser(description="trousseau crans")
701
    parser.add_argument('-s', '--server', default='default',
Vincent Le gallic's avatar
Vincent Le gallic committed
702 703
        help="Utilisation d'un serveur alternatif (test, backup, etc)")
    parser.add_argument('-v', '--verbose', action='store_true', default=False,
Daniel STAN's avatar
Daniel STAN committed
704
        help="Mode verbeux")
705 706
    parser.add_argument('-q', '--quiet', action='store_true', default=False,
        help="Mode silencieux. Cache les message d'erreurs (override --verbose).")
Vincent Le gallic's avatar
Vincent Le gallic committed
707
    parser.add_argument('-c', '--clipboard', action='store_true', default=None,
Daniel STAN's avatar
Daniel STAN committed
708
        help="Stocker le mot de passe dans le presse papier")
Vincent Le gallic's avatar
Vincent Le gallic committed
709
    parser.add_argument('--no-clip', '--noclip', '--noclipboard', action='store_false', default=None,
Daniel STAN's avatar
Daniel STAN committed
710 711
        dest='clipboard',
        help="Ne PAS stocker le mot de passe dans le presse papier")
Vincent Le gallic's avatar
Vincent Le gallic committed
712 713
    parser.add_argument('-f', '--force', action='store_true', default=False,
        help="Ne pas demander confirmation")
Daniel STAN's avatar
Daniel STAN committed
714 715 716

    # Actions possibles
    action_grp = parser.add_mutually_exclusive_group(required=False)
Vincent Le gallic's avatar
Vincent Le gallic committed
717 718
    action_grp.add_argument('-e', '--edit', action='store_const', dest='action',
        default=show_file, const=edit_file,
Daniel STAN's avatar
Daniel STAN committed
719
        help="Editer (ou créer)")
Vincent Le gallic's avatar
Vincent Le gallic committed
720 721 722 723 724 725 726 727
    action_grp.add_argument('--view', action='store_const', dest='action',
        default=show_file, const=show_file,
        help="Voir le fichier")
    action_grp.add_argument('--remove', action='store_const', dest='action',
        default=show_file, const=remove_file,
        help="Effacer le fichier")
    action_grp.add_argument('-l', '--list', action='store_const', dest='action',
        default=show_file, const=show_files,
Daniel STAN's avatar
Daniel STAN committed
728
        help="Lister les fichiers")
Vincent Le gallic's avatar
Vincent Le gallic committed
729 730
    action_grp.add_argument('--check-keys', action='store_const', dest='action',
        default=show_file, const=my_check_keys,
Daniel STAN's avatar
Daniel STAN committed
731
        help="Vérifier les clés")
Vincent Le gallic's avatar
Vincent Le gallic committed
732 733
    action_grp.add_argument('--update-keys', action='store_const', dest='action',
        default=show_file, const=my_update_keys,
Daniel STAN's avatar
Daniel STAN committed
734
        help="Mettre à jour les clés")
Vincent Le gallic's avatar
Vincent Le gallic committed
735 736 737 738 739 740
    action_grp.add_argument('--list-roles', action='store_const', dest='action',
        default=show_file, const=show_roles,
        help="Lister les rôles existants")
    action_grp.add_argument('--list-servers', action='store_const', dest='action',
        default=show_file, const=show_servers,
        help="Lister les serveurs")
741 742
    action_grp.add_argument('--recrypt-files', action='store_const', dest='action',
        default=show_file, const=recrypt_files,
743 744 745
        help="""Rechiffrer les mots de passe.
                (Avec les mêmes rôles que ceux qu'ils avant.
                 Cela sert à mettre à jour les recipients pour qui un password est chiffré)""")
Vincent Le gallic's avatar
Vincent Le gallic committed
746 747

    parser.add_argument('--roles', nargs='?', default=None,
748 749 750 751 752
        help="""Liste de roles (séparés par des virgules).
                Avec --edit, le fichier sera chiffré pour exactement ces roles
                (par défaut, tous vos rôles en écriture seront utilisés).
                Avec --recrypt-files, tous les fichiers ayant au moins un de ces roles (et pour lesquels vous avez le droit d'écriture) seront rechiffrés
                (par défaut, tous les fichiers pour lesquels vous avez les droits en écriture sont rechiffrés).""")
Vincent Le gallic's avatar
Vincent Le gallic committed
753
    parser.add_argument('fname', nargs='?', default=None,
Daniel STAN's avatar
Daniel STAN committed
754
        help="Nom du fichier à afficher")
Vincent Le gallic's avatar
Vincent Le gallic committed
755
    
Daniel STAN's avatar
Daniel STAN committed
756
    parsed = parser.parse_args(sys.argv[1:])
757
    SERVER = config.servers[parsed.server]
758 759
    QUIET = parsed.quiet
    VERB = parsed.verbose and not QUIET
Daniel STAN's avatar
Daniel STAN committed
760 761
    if parsed.clipboard != None:
        CLIPBOARD = parsed.clipboard
Daniel STAN's avatar
Daniel STAN committed
762
    FORCED = parsed.force
763
    NEWROLES = parse_roles(parsed.roles)
Vincent Le gallic's avatar
Vincent Le gallic committed
764
    
765
    if NEWROLES != False:
Daniel STAN's avatar
Daniel STAN committed
766 767 768
        if parsed.action.func_code.co_argcount == 0:
            parsed.action()
        elif parsed.fname == None:
769
            if not QUIET:
Vincent Le gallic's avatar
Vincent Le gallic committed
770
                print(u"Vous devez fournir un nom de fichier avec cette commande".encode("utf-8"))
771 772
                parser.print_help()
            sys.exit(1)
Daniel STAN's avatar
Daniel STAN committed
773
        else:
Daniel STAN's avatar
Daniel STAN committed
774
            parsed.action(parsed.fname)
775 776
    
    saveclipboard(restore=True)
Daniel STAN's avatar
init  
Daniel STAN committed
777