client.py 27.7 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 to gpg binary
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:
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:
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

def check_keys():
    """Vérifie les clés existantes"""
320 321
    if VERB:
        print("M : l'uid correspond au mail du fingerprint\nC : confiance OK (inclu la vérification de non expiration).\n")
322
    keys = all_keys()
323 324
    _, gpgout = gpg('list-keys')
    localring = parse_keys(gpgout)
325 326 327
    failed = False
    for (mail, fpr) in keys.values():
        if fpr:
328
            if VERB:
329
                print((u"Checking %s… " % (mail)).encode("utf-8"), end="")
330
            key = localring.get(fpr, None)
331
            # On vérifie qu'on possède la clé…
332
            if not key is None:
333
                # …qu'elle correspond au mail…
334
                if any([u"<%s>" % (mail,) in u["uid"] for u in key["uids"]]):
335 336
                    if VERB:
                        print("M ", end="")
337
                    meaning, trustvalue = GPG_TRUSTLEVELS[key["trustletter"]]
338 339
                    # … et qu'on lui fait confiance
                    if not trustvalue:
340
                        print((u"--> Fail on %s:%s\nLa confiance en la clé est : %s" % (fpr, mail, meaning,)).encode("utf-8"))
341
                        failed = True
342 343
                    elif VERB:
                        print("C ", end="")
344
                else:
Vincent Le gallic's avatar
Vincent Le gallic committed
345
                    print((u"--> Fail on %s:%s\n!! Le fingerprint et le mail ne correspondent pas !" % (fpr, mail)).encode("utf-8"))
346 347
                    failed = True
            else:
Vincent Le gallic's avatar
Vincent Le gallic committed
348
                print((u"--> Fail on %s:%s\nPas (ou trop) de clé avec ce fingerprint." % (fpr, mail)).encode("utf-8"))
349
                failed = True
350 351
            if VERB:
                print("")
352
    return not failed
Daniel STAN's avatar
init  
Daniel STAN committed
353

354 355 356 357 358 359 360 361 362 363
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
364
    """Renvoie la liste des "username : mail (fingerprint)" """
365
    allkeys = all_keys()
366
    return [u"%s : %s (%s)" % (rec, allkeys[rec][0], allkeys[rec][1])
367
               for rec in get_recipients_of_roles(roles) if allkeys[rec][1]]
368

Daniel STAN's avatar
init  
Daniel STAN committed
369
def encrypt(roles, contents):
370
    """Chiffre ``contents`` pour les ``roles`` donnés"""
Daniel STAN's avatar
init  
Daniel STAN committed
371
    allkeys = all_keys()
372
    recipients = get_recipients_of_roles(roles)
Daniel STAN's avatar
init  
Daniel STAN committed
373
    
374
    fpr_recipients = []
Daniel STAN's avatar
init  
Daniel STAN committed
375
    for recipient in recipients:
376 377 378 379
        fpr = allkeys[recipient][1]
        if fpr:
            fpr_recipients.append("-r")
            fpr_recipients.append(fpr)
Vincent Le gallic's avatar
Vincent Le gallic committed
380
    
381
    stdin, stdout = gpg("encrypt", fpr_recipients)
382
    stdin.write(contents.encode("utf-8"))
Daniel STAN's avatar
init  
Daniel STAN committed
383
    stdin.close()
384
    out = stdout.read().decode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
385
    if out == '':
386
        return [False, u"Échec de chiffrement"]
Daniel STAN's avatar
Daniel STAN committed
387
    else:
388
        return [True, out]
Daniel STAN's avatar
init  
Daniel STAN committed
389 390 391 392

def decrypt(contents):
    """Déchiffre le contenu"""
    stdin, stdout = gpg("decrypt")
393
    stdin.write(contents.encode("utf-8"))
Daniel STAN's avatar
init  
Daniel STAN committed
394
    stdin.close()
395
    return stdout.read().decode("utf-8")
Daniel STAN's avatar
init  
Daniel STAN committed
396 397 398 399

def put_password(name, roles, contents):
    """Dépose le mot de passe après l'avoir chiffré pour les
    destinataires donnés"""
400
    success, enc_pwd_or_error = encrypt(roles, contents)
401 402
    if NEWROLES != None:
        roles = NEWROLES
Daniel STAN's avatar
Daniel STAN committed
403
        if VERB:
Vincent Le gallic's avatar
Vincent Le gallic committed
404
            print(u"Pas de nouveaux rôles".encode("utf-8"))
405 406
    if success:
        enc_pwd = enc_pwd_or_error
407
        return put_files([{'filename' : name, 'roles' : roles, 'contents' : enc_pwd}])[0]
Daniel STAN's avatar
Daniel STAN committed
408
    else:
409 410
        error = enc_pwd_or_error
        return [False, error]
Daniel STAN's avatar
init  
Daniel STAN committed
411 412 413

def get_password(name):
    """Récupère le mot de passe donné par name"""
414
    gotit, remotefile = get_files([name])[0]
415 416 417
    if gotit:
        remotefile = decrypt(remotefile['contents'])
    return [gotit, remotefile]
Daniel STAN's avatar
init  
Daniel STAN committed
418

Vincent Le gallic's avatar
Vincent Le gallic committed
419
######
Daniel STAN's avatar
Daniel STAN committed
420 421
## Interface

422
def editor(texte, annotations=u""):
423 424 425
    """ 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
426
    
427 428 429
    # 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
430
    atexit.register(f.close)
431 432 433
    if annotations:
        annotations = "# " + annotations.replace("\n", "\n# ")
    f.write((texte + "\n" + annotations).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
434
    f.flush()
Daniel STAN's avatar
Daniel STAN committed
435
    proc = subprocess.Popen([os.getenv('EDITOR', '/usr/bin/editor'), f.name])
Vincent Le gallic's avatar
Vincent Le gallic committed
436
    os.waitpid(proc.pid, 0)
Daniel STAN's avatar
Daniel STAN committed
437
    f.seek(0)
438
    ntexte = f.read().decode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
439
    f.close()
440
    ntexte = u'\n'.join(filter(lambda l: not l.startswith('#'), ntexte.split('\n')))
441
    return ntexte
Daniel STAN's avatar
Daniel STAN committed
442 443

def show_files():
Vincent Le gallic's avatar
Vincent Le gallic committed
444
    """Affiche la liste des fichiers disponibles sur le serveur distant"""
Vincent Le gallic's avatar
Vincent Le gallic committed
445
    print(u"Liste des fichiers disponibles :".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
446
    my_roles = get_my_roles()
447 448 449 450 451
    files = all_files()
    keys = files.keys()
    keys.sort()
    for fname in keys:
        froles = files[fname]
Daniel STAN's avatar
Daniel STAN committed
452
        access = set(my_roles).intersection(froles) != set([])
Vincent Le gallic's avatar
Vincent Le gallic committed
453 454
        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"))
455
    
Daniel STAN's avatar
Daniel STAN committed
456
def show_roles():
Vincent Le gallic's avatar
Vincent Le gallic committed
457
    """Affiche la liste des roles existants"""
Vincent Le gallic's avatar
Vincent Le gallic committed
458
    print(u"Liste des roles disponibles".encode("utf-8"))
459
    for (role, usernames) in all_roles().iteritems():
Vincent Le gallic's avatar
Vincent Le gallic committed
460
        if not role.endswith('-w'):
461
            print((u" * %s : %s" % (role, ", ".join(usernames))).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
462

463
def show_servers():
Vincent Le gallic's avatar
Vincent Le gallic committed
464
    """Affiche la liste des serveurs disponibles"""
Vincent Le gallic's avatar
Vincent Le gallic committed
465
    print(u"Liste des serveurs disponibles".encode("utf-8"))
466
    for server in config.servers.keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
467
        print((u" * " + server).encode("utf-8"))
468

469 470
old_clipboard = None
def saveclipboard(restore=False):
Vincent Le gallic's avatar
Vincent Le gallic committed
471
    """Enregistre le contenu du presse-papier. Le rétablit si ``restore=True``"""
472 473 474 475
    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
476 477
    proc = subprocess.Popen(['xclip', act, '-selection', 'clipboard'],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr)
478 479 480
    if not restore:
        old_clipboard = proc.stdout.read()
    else:
481
        raw_input(u"Appuyez sur Entrée pour récupérer le contenu précédent du presse papier.".encode("utf-8"))
482 483 484 485
        proc.stdin.write(old_clipboard)
    proc.stdin.close()
    proc.stdout.close()

Daniel STAN's avatar
Daniel STAN committed
486
def clipboard(texte):
Vincent Le gallic's avatar
Vincent Le gallic committed
487
    """Place ``texte`` dans le presse-papier en mémorisant l'ancien contenu."""
488
    saveclipboard()
Vincent Le gallic's avatar
Vincent Le gallic committed
489 490
    proc =subprocess.Popen(['xclip', '-selection', 'clipboard'],\
        stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr)
491
    proc.stdin.write(texte.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
492
    proc.stdin.close()
493
    return u"[Le mot de passe a été mis dans le presse papier]"
Daniel STAN's avatar
Daniel STAN committed
494 495 496


def show_file(fname):
Vincent Le gallic's avatar
Vincent Le gallic committed
497
    """Affiche le contenu d'un fichier"""
498
    gotit, value = get_files([fname])[0]
499 500
    if not gotit:
        print(value.encode("utf-8")) # value contient le message d'erreur
Daniel STAN's avatar
Daniel STAN committed
501
        return
Vincent Le gallic's avatar
Vincent Le gallic committed
502
    (sin, sout) = gpg('decrypt')
503
    sin.write(value['contents'].encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
504
    sin.close()
505 506
    texte = sout.read().decode("utf-8")
    ntexte = u""
Daniel STAN's avatar
Daniel STAN committed
507 508 509 510 511
    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:
512
            hidden = True
Daniel STAN's avatar
Daniel STAN committed
513 514 515
            line = clipboard(catchPass.group(1))
        ntexte += line + '\n'
    showbin = "cat" if hidden else "less"
516
    proc = subprocess.Popen([showbin], stdin=subprocess.PIPE)
Daniel STAN's avatar
Daniel STAN committed
517
    out = proc.stdin
518 519
    raw = u"Fichier %s:\n\n%s-----\nVisible par: %s\n" % (fname, ntexte, ','.join(value['roles']))
    out.write(raw.encode("utf-8"))
520
    out.close()
Daniel STAN's avatar
Daniel STAN committed
521
    os.waitpid(proc.pid, 0)
522

Daniel STAN's avatar
Daniel STAN committed
523 524
        
def edit_file(fname):
Vincent Le gallic's avatar
Vincent Le gallic committed
525
    """Modifie/Crée un fichier"""
526
    gotit, value = get_files([fname])[0]
Daniel STAN's avatar
Daniel STAN committed
527
    nfile = False
528
    annotations = u""
529
    if not gotit and not "pas les droits" in value:
Daniel STAN's avatar
Daniel STAN committed
530
        nfile = True
Vincent Le gallic's avatar
Vincent Le gallic committed
531
        print(u"Fichier introuvable".encode("utf-8"))
Vincent Le gallic's avatar
Vincent Le gallic committed
532
        if not confirm(u"Créer fichier ?"):
Daniel STAN's avatar
Daniel STAN committed
533
            return
534
        annotations += u"""Ceci est un fichier initial contenant un mot de passe
Vincent Le gallic's avatar
Vincent Le gallic committed
535 536
aléatoire, pensez à rajouter une ligne "login: ${login}"
Enregistrez le fichier vide pour annuler.\n"""
537
        texte = u"pass: %s\n" % gen_password()
538 539 540
        roles = get_my_roles()
        # Par défaut les roles d'un fichier sont ceux en écriture de son
        # créateur
541
        roles = [ r[:-2] for r in roles if r.endswith('-w') ]
542
        if roles == []:
Vincent Le gallic's avatar
Vincent Le gallic committed
543
            print(u"Vous ne possédez aucun rôle en écriture ! Abandon.".encode("utf-8"))
544
            return
Vincent Le gallic's avatar
Vincent Le gallic committed
545
        value = {'roles' : roles}
546 547 548
    elif not gotit:
        print(value.encode("utf-8")) # value contient le message d'erreur
        return
Daniel STAN's avatar
Daniel STAN committed
549
    else:
Vincent Le gallic's avatar
Vincent Le gallic committed
550
        (sin, sout) = gpg('decrypt')
551
        sin.write(value['contents'].encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
552
        sin.close()
553
        texte = sout.read().decode("utf-8")
554 555
    # 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']
556
    
Vincent Le gallic's avatar
Vincent Le gallic committed
557 558
    annotations += u"""Ce fichier sera chiffré pour les rôles suivants :\n%s\n
C'est-à-dire pour les utilisateurs suivants :\n%s""" % (
559 560 561 562 563
           ', '.join(value['roles']),
           '\n'.join(' %s' % rec for rec in get_dest_of_roles(value['roles']))
        )
        
    ntexte = editor(texte, annotations)
564
    
565
    if ((not nfile and ntexte in [u'', texte] and NEWROLES == None) or # Fichier existant vidé ou inchangé
566 567
        (nfile and ntexte == u'')):                                  # Nouveau fichier créé vide
        print(u"Pas de modification effectuée".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
568
    else:
Daniel STAN's avatar
Daniel STAN committed
569
        ntexte = texte if ntexte == None else ntexte
570 571
        success, message = put_password(fname, value['roles'], ntexte)
        print(message.encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
572 573

def confirm(text):
Vincent Le gallic's avatar
Vincent Le gallic committed
574
    """Demande confirmation, sauf si on est mode ``FORCED``"""
Daniel STAN's avatar
Daniel STAN committed
575 576
    if FORCED: return True
    while True:
Vincent Le gallic's avatar
Vincent Le gallic committed
577
        out = raw_input((text + u' (O/N)').encode("utf-8")).lower()
Daniel STAN's avatar
Daniel STAN committed
578 579 580 581 582 583
        if out == 'o':
            return True
        elif out == 'n':
            return False

def remove_file(fname):
Vincent Le gallic's avatar
Vincent Le gallic committed
584
    """Supprime un fichier"""
585
    if not confirm(u'Êtes-vous sûr de vouloir supprimer %s ?' % fname):
Daniel STAN's avatar
Daniel STAN committed
586
        return
587 588
    message = rm_file(fname)
    print(message.encode("utf-8"))
Vincent Le gallic's avatar
Vincent Le gallic committed
589

Daniel STAN's avatar
Daniel STAN committed
590 591

def my_check_keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
592
    """Vérifie les clés et affiche un message en fonction du résultat"""
Vincent Le gallic's avatar
Vincent Le gallic committed
593 594
    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
595 596

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

600
def recrypt_files():
Vincent Le gallic's avatar
Vincent Le gallic committed
601
    """Rechiffre les fichiers"""
602 603
    # 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
604
    my_roles = get_my_roles()
605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635
    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)):
636
            print(u"%s : %s" % (to_put[i]['filename'], results[i][1]))
637 638
    else:
        print(u"Aucun fichier n'a besoin d'être rechiffré".encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
639 640

def parse_roles(strroles):
641 642
    """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
643 644
    if strroles == None: return None
    roles = all_roles()
645
    my_roles = filter(lambda r: SERVER['user'] in roles[r], roles.keys())
646
    my_roles_w = [ r[:-2] for r in my_roles if r.endswith('-w') ]
Daniel STAN's avatar
Daniel STAN committed
647 648 649 650
    ret = set()
    writable = False
    for role in strroles.split(','):
        if role not in roles.keys():
Vincent Le gallic's avatar
Vincent Le gallic committed
651
            print((u"Le rôle %s n'existe pas !" % role).encode("utf-8"))
Daniel STAN's avatar
Daniel STAN committed
652 653
            return False
        if role.endswith('-w'):
Vincent Le gallic's avatar
Vincent Le gallic committed
654
            print((u"Le rôle %s ne devrait pas être utilisé ! (utilisez %s)")
655
                   % (role, role[:-2])).encode("utf-8")
Daniel STAN's avatar
Daniel STAN committed
656 657 658 659 660
            return False
        writable = writable or role in my_roles_w
        ret.add(role)
    
    if not FORCED and not writable:
661
        if not confirm(u"""Vous vous apprêtez à perdre vos droits d'écritures\
662
(ROLES ne contient pas %s) sur ce fichier, continuer ?""" %
Daniel STAN's avatar
Daniel STAN committed
663
            ", ".join(my_roles_w)):
Daniel STAN's avatar
Daniel STAN committed
664 665
            return False
    return list(ret)
Daniel STAN's avatar
Daniel STAN committed
666 667

if __name__ == "__main__":
Daniel STAN's avatar
Daniel STAN committed
668
    parser = argparse.ArgumentParser(description="trousseau crans")
669
    parser.add_argument('-s', '--server', default='default',
Vincent Le gallic's avatar
Vincent Le gallic committed
670 671
        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
672
        help="Mode verbeux")
673 674
    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
675
    parser.add_argument('-c', '--clipboard', action='store_true', default=None,
Daniel STAN's avatar
Daniel STAN committed
676
        help="Stocker le mot de passe dans le presse papier")
Vincent Le gallic's avatar
Vincent Le gallic committed
677
    parser.add_argument('--no-clip', '--noclip', '--noclipboard', action='store_false', default=None,
Daniel STAN's avatar
Daniel STAN committed
678 679
        dest='clipboard',
        help="Ne PAS stocker le mot de passe dans le presse papier")
Vincent Le gallic's avatar
Vincent Le gallic committed
680 681
    parser.add_argument('-f', '--force', action='store_true', default=False,
        help="Ne pas demander confirmation")
Daniel STAN's avatar
Daniel STAN committed
682 683 684

    # Actions possibles
    action_grp = parser.add_mutually_exclusive_group(required=False)
Vincent Le gallic's avatar
Vincent Le gallic committed
685 686
    action_grp.add_argument('-e', '--edit', action='store_const', dest='action',
        default=show_file, const=edit_file,
Daniel STAN's avatar
Daniel STAN committed
687
        help="Editer (ou créer)")
Vincent Le gallic's avatar
Vincent Le gallic committed
688 689 690 691 692 693 694 695
    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
696
        help="Lister les fichiers")
Vincent Le gallic's avatar
Vincent Le gallic committed
697 698
    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
699
        help="Vérifier les clés")
Vincent Le gallic's avatar
Vincent Le gallic committed
700 701
    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
702
        help="Mettre à jour les clés")
Vincent Le gallic's avatar
Vincent Le gallic committed
703 704 705 706 707 708
    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")
709 710
    action_grp.add_argument('--recrypt-files', action='store_const', dest='action',
        default=show_file, const=recrypt_files,
711
        help="Rechiffrer les mots de passe. (Avec les mêmes rôles qu'avant, sert à rajouter un lecteur)")
Vincent Le gallic's avatar
Vincent Le gallic committed
712 713

    parser.add_argument('--roles', nargs='?', default=None,
714 715 716 717 718
        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
719
    parser.add_argument('fname', nargs='?', default=None,
Daniel STAN's avatar
Daniel STAN committed
720
        help="Nom du fichier à afficher")
Vincent Le gallic's avatar
Vincent Le gallic committed
721
    
Daniel STAN's avatar
Daniel STAN committed
722
    parsed = parser.parse_args(sys.argv[1:])
723
    SERVER = config.servers[parsed.server]
724 725
    QUIET = parsed.quiet
    VERB = parsed.verbose and not QUIET
Daniel STAN's avatar
Daniel STAN committed
726 727
    if parsed.clipboard != None:
        CLIPBOARD = parsed.clipboard
Daniel STAN's avatar
Daniel STAN committed
728
    FORCED = parsed.force
729
    NEWROLES = parse_roles(parsed.roles)
Vincent Le gallic's avatar
Vincent Le gallic committed
730
    
731
    if NEWROLES != False:
Daniel STAN's avatar
Daniel STAN committed
732 733 734
        if parsed.action.func_code.co_argcount == 0:
            parsed.action()
        elif parsed.fname == None:
735
            if not QUIET:
Vincent Le gallic's avatar
Vincent Le gallic committed
736
                print(u"Vous devez fournir un nom de fichier avec cette commande".encode("utf-8"))
737 738
                parser.print_help()
            sys.exit(1)
Daniel STAN's avatar
Daniel STAN committed
739
        else:
Daniel STAN's avatar
Daniel STAN committed
740
            parsed.action(parsed.fname)
741 742
    
    saveclipboard(restore=True)
Daniel STAN's avatar
init  
Daniel STAN committed
743