cranspasswords.py 16.3 KB
Newer Older
Daniel STAN's avatar
init  
Daniel STAN committed
1
2
3
4
5
6
7
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""cranspasswords: gestion des mots de passe du Cr@ns"""

import sys
import subprocess
import json
Daniel STAN's avatar
Daniel STAN committed
8
9
10
import tempfile
import os
import atexit
Daniel STAN's avatar
Daniel STAN committed
11
import argparse
Daniel STAN's avatar
Daniel STAN committed
12
import re
13
14
15
import random
import string
import datetime
16
17
18
19
20
try:
    import clientconfig as config
except ImportError:
    print "Read the README"
    sys.exit(1)
Daniel STAN's avatar
init  
Daniel STAN committed
21

Daniel STAN's avatar
Daniel STAN committed
22
23
24
25
## Password pattern in files:
PASS = re.compile('[\t ]*pass(?:word)?[\t ]*:[\t ]*(.*)\r?\n?$', \
        flags=re.IGNORECASE)

Daniel STAN's avatar
init  
Daniel STAN committed
26
27
28
29
30
31
32
33
34
35
36
######
## GPG Definitions

GPG = '/usr/bin/gpg'
GPG_ARGS = {
    'decrypt': ['-d'],
    'encrypt': ['--armor', '-es'],
    'fingerprint': ['--fingerprint'],
    'receive-keys': ['--recv-keys'],
    }

Daniel STAN's avatar
Daniel STAN committed
37
38
DEBUG = False
VERB = False
Daniel STAN's avatar
Daniel STAN committed
39
40
# Par défaut, place-t-on le mdp dans le presse-papier ?
CLIPBOARD = bool(os.getenv('DISPLAY')) and os.path.exists('/usr/bin/xclip')
Daniel STAN's avatar
Daniel STAN committed
41
42
FORCED = False #Mode interactif qui demande confirmation
NROLES = None     # Droits à définir sur le fichier en édition
43
SERVER = None
Daniel STAN's avatar
Daniel STAN committed
44

Daniel STAN's avatar
init  
Daniel STAN committed
45
46
47
48
49
50
51
def gpg(command, args = None):
    """Lance gpg pour la commande donnée avec les arguments
    donnés. Renvoie son entrée standard et sa sortie standard."""
    full_command = [GPG]
    full_command.extend(GPG_ARGS[command])
    if args:
        full_command.extend(args)
Daniel STAN's avatar
Daniel STAN committed
52
    if VERB:
Daniel STAN's avatar
Daniel STAN committed
53
54
55
56
57
        stderr=sys.stderr
    else:
        stderr=subprocess.PIPE
        full_command.extend(['--debug-level=1'])
    #print full_command
Daniel STAN's avatar
init  
Daniel STAN committed
58
59
60
    proc = subprocess.Popen(full_command,
                            stdin = subprocess.PIPE,
                            stdout = subprocess.PIPE,
Daniel STAN's avatar
Daniel STAN committed
61
                            stderr = stderr,
Daniel STAN's avatar
init  
Daniel STAN committed
62
                            close_fds = True)
Daniel STAN's avatar
Daniel STAN committed
63
    if not VERB:
Daniel STAN's avatar
Daniel STAN committed
64
        proc.stderr.close()
Daniel STAN's avatar
init  
Daniel STAN committed
65
66
    return proc.stdin, proc.stdout

67
68
69
70
71
72
73
74
75
76
77
78

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

    def __call__(self):
        if self.val==None:
            self.val = self.f()
        return self.val

Daniel STAN's avatar
init  
Daniel STAN committed
79
80
81
82
83
84
85
######
## Remote commands


def ssh(command, arg = None):
    """Lance ssh avec les arguments donnés. Renvoie son entrée
    standard et sa sortie standard."""
86
    full_command = list(SERVER['server_cmd'])
Daniel STAN's avatar
init  
Daniel STAN committed
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
    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)
    if stdin_contents:
        sshin.write(json.dumps(stdin_contents))
        sshin.close()
    return json.loads(sshout.read())

107
@simple_memoize
Daniel STAN's avatar
init  
Daniel STAN committed
108
109
110
111
def all_keys():
    """Récupère les clés du serveur distant"""
    return remote_command("listkeys")

112
@simple_memoize
Daniel STAN's avatar
init  
Daniel STAN committed
113
114
115
116
def all_roles():
    """Récupère les roles du serveur distant"""
    return remote_command("listroles")

117
@simple_memoize
Daniel STAN's avatar
init  
Daniel STAN committed
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def all_files():
    """Récupère les fichiers du serveur distant"""
    return remote_command("listfiles")

def get_file(filename):
    """Récupère le contenu du fichier distant"""
    return remote_command("getfile", filename)

def put_file(filename, roles, contents):
    """Dépose le fichier sur le serveur distant"""
    return remote_command("putfile", filename, {'roles': roles,
                                                'contents': contents})
def rm_file(filename):
    """Supprime le fichier sur le serveur distant"""
    return remote_command("rmfile", filename)

134
@simple_memoize
Daniel STAN's avatar
Daniel STAN committed
135
136
137
def get_my_roles():
    """Retoure la liste des rôles perso"""
    allr = all_roles()
138
    return filter(lambda role: SERVER['user'] in allr[role],allr.keys())
Daniel STAN's avatar
Daniel STAN committed
139

140
141
142
143
144
145
146
def gen_password():
    """Generate random password"""
    random.seed(datetime.datetime.now().microsecond)
    chars = string.letters + string.digits + '/=+*'
    length = 15
    return ''.join([random.choice(chars) for _ in xrange(length)])

Daniel STAN's avatar
init  
Daniel STAN committed
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
######
## Local commands

def update_keys():
    """Met à jour les clés existantes"""

    keys = all_keys()

    _, stdout = gpg("receive-keys", [key for _, key in keys.values() if key])
    return stdout.read()

def check_keys():
    """Vérifie les clés existantes"""

    keys = all_keys()

    for mail, key in keys.values():
        if key:
            _, stdout = gpg("fingerprint", [key])
Daniel STAN's avatar
Daniel STAN committed
166
            if VERB:   print "Checking %s" % mail
Daniel STAN's avatar
Daniel STAN committed
167
            if str("<%s>" % mail.lower()) not in stdout.read().lower():
Daniel STAN's avatar
Daniel STAN committed
168
                if VERB:   print "-->Fail on %s" % mail
Daniel STAN's avatar
init  
Daniel STAN committed
169
170
171
172
173
                break
    else:
        return True
    return False

174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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):
    allkeys = all_keys()
    return ["%s (%s -> %s)" % (rec, allkeys[rec][0], allkeys[rec][1]) for rec in \
        get_recipients_of_roles(roles)]

Daniel STAN's avatar
init  
Daniel STAN committed
189
190
191
192
def encrypt(roles, contents):
    """Chiffre le contenu pour les roles donnés"""

    allkeys = all_keys()
193
    recipients = get_recipients_of_roles(roles)
Daniel STAN's avatar
init  
Daniel STAN committed
194
195
196
197
198
199
    
    email_recipients = []
    for recipient in recipients:
        email, key = allkeys[recipient]
        if key:
            email_recipients.append("-r")
200
            email_recipients.append(key)
Daniel STAN's avatar
init  
Daniel STAN committed
201
202
203
204

    stdin, stdout = gpg("encrypt", email_recipients)
    stdin.write(contents)
    stdin.close()
Daniel STAN's avatar
Daniel STAN committed
205
206
    out = stdout.read()
    if out == '':
Daniel STAN's avatar
Daniel STAN committed
207
        if VERB: print "Échec de chiffrement"
Daniel STAN's avatar
Daniel STAN committed
208
209
210
        return None
    else:
        return out
Daniel STAN's avatar
init  
Daniel STAN committed
211
212
213
214
215
216
217
218
219
220
221
222

def decrypt(contents):
    """Déchiffre le contenu"""
    stdin, stdout = gpg("decrypt")
    stdin.write(contents)
    stdin.close()
    return stdout.read()

def put_password(name, roles, contents):
    """Dépose le mot de passe après l'avoir chiffré pour les
    destinataires donnés"""
    enc_pwd = encrypt(roles, contents)
Daniel STAN's avatar
Daniel STAN committed
223
224
    if NROLES != None:
        roles = NROLES
Daniel STAN's avatar
Daniel STAN committed
225
226
        if VERB:
            print "Pas de nouveaux rôles"
Daniel STAN's avatar
Daniel STAN committed
227
228
229
230
    if enc_pwd <> None:
        return put_file(name, roles, enc_pwd)
    else:
        return False
Daniel STAN's avatar
init  
Daniel STAN committed
231
232
233
234
235
236

def get_password(name):
    """Récupère le mot de passe donné par name"""
    remotefile = get_file(name)
    return decrypt(remotefile['contents'])

Daniel STAN's avatar
Daniel STAN committed
237
238
## Interface

239
240
241
242
243
244
245
246
def editor(texte, annotations=""):
    """ Lance $EDITOR sur texte.
    Renvoie le nouveau texte si des modifications ont été apportées, ou None
    """

    # Avoid syntax hilight with ".txt". Would be nice to have some colorscheme
    # for annotations ...
    f = tempfile.NamedTemporaryFile(suffix='.txt')
Daniel STAN's avatar
Daniel STAN committed
247
248
    atexit.register(f.close)
    f.write(texte)
249
250
    for l in annotations.split('\n'):
        f.write("# %s\n" % l.encode('utf-8'))
Daniel STAN's avatar
Daniel STAN committed
251
252
253
254
255
256
    f.flush()
    proc = subprocess.Popen(os.getenv('EDITOR') + ' ' + f.name,shell=True)
    os.waitpid(proc.pid,0)
    f.seek(0)
    ntexte = f.read()
    f.close()
257
258
259
260
    ntexte = '\n'.join(filter(lambda l: not l.startswith('#'), ntexte.split('\n')))
    if texte != ntexte:
        return ntexte
    return None
Daniel STAN's avatar
Daniel STAN committed
261
262

def show_files():
Daniel STAN's avatar
Daniel STAN committed
263
    proc = subprocess.Popen("cat",stdin=subprocess.PIPE,shell=True)
264
265
    out = proc.stdin
    out.write("""Liste des fichiers disponibles\n""" )
Daniel STAN's avatar
Daniel STAN committed
266
    my_roles = get_my_roles()
267
268
269
270
271
    files = all_files()
    keys = files.keys()
    keys.sort()
    for fname in keys:
        froles = files[fname]
Daniel STAN's avatar
Daniel STAN committed
272
        access = set(my_roles).intersection(froles) != set([])
273
274
275
276
277
278
        out.write(" %s %s (%s)\n" % ((access and '+' or '-'),fname,", ".join(froles)))
    out.write("""--Mes roles: %s\n""" % \
        ", ".join(my_roles))
    
    out.close()
    os.waitpid(proc.pid,0)
Daniel STAN's avatar
Daniel STAN committed
279
280

def show_roles():
Daniel STAN's avatar
Daniel STAN committed
281
    print """Liste des roles disponibles"""
Daniel STAN's avatar
Daniel STAN committed
282
283
284
285
    for role in all_roles().keys():
        if role.endswith('-w'): continue
        print " * " + role 

286
287
288
289
290
def show_servers():
    print """Liste des serveurs disponibles"""
    for server in config.servers.keys():
        print " * " + server

291
292
293
294
295
296
297
298
299
300
301
old_clipboard = None
def saveclipboard(restore=False):
    global old_clipboard
    if restore and old_clipboard == None:
        return
    act = '-in' if restore else '-out'
    proc =subprocess.Popen(['xclip',act,'-selection','clipboard'],\
        stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=sys.stderr)
    if not restore:
        old_clipboard = proc.stdout.read()
    else:
302
        raw_input("Appuyez sur Entrée pour récupérer le contenu précédent du presse papier.")
303
304
305
306
        proc.stdin.write(old_clipboard)
    proc.stdin.close()
    proc.stdout.close()

Daniel STAN's avatar
Daniel STAN committed
307
def clipboard(texte):
308
    saveclipboard()
Daniel STAN's avatar
Daniel STAN committed
309
310
311
312
    proc =subprocess.Popen(['xclip','-selection','clipboard'],\
        stdin=subprocess.PIPE,stdout=sys.stdout,stderr=sys.stderr)
    proc.stdin.write(texte)
    proc.stdin.close()
313
    return "[Le mot de passe a été mis dans le presse papier]"
Daniel STAN's avatar
Daniel STAN committed
314
315
316
317
318


def show_file(fname):
    value = get_file(fname)
    if value == False:
Daniel STAN's avatar
Daniel STAN committed
319
320
        print "Fichier introuvable"
        return
Daniel STAN's avatar
Daniel STAN committed
321
322
323
    (sin,sout) = gpg('decrypt')
    sin.write(value['contents'])
    sin.close()
Daniel STAN's avatar
Daniel STAN committed
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
    texte = sout.read()
    ntexte = ""
    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:
            hidden=True
            line = clipboard(catchPass.group(1))
        ntexte += line + '\n'
    showbin = "cat" if hidden else "less"
    proc = subprocess.Popen(showbin, stdin=subprocess.PIPE, shell=True)
    out = proc.stdin
    out.write("Fichier %s:\n\n" % fname)
    out.write(ntexte)
339
340
341
    out.write("-----\n")
    out.write("Visible par: %s\n" % ','.join(value['roles']))
    out.close()
Daniel STAN's avatar
Daniel STAN committed
342
    os.waitpid(proc.pid, 0)
343

Daniel STAN's avatar
Daniel STAN committed
344
345
346
        
def edit_file(fname):
    value = get_file(fname)
Daniel STAN's avatar
Daniel STAN committed
347
    nfile = False
348
    annotations = u""
Daniel STAN's avatar
Daniel STAN committed
349
    if value == False:
Daniel STAN's avatar
Daniel STAN committed
350
351
352
353
        nfile = True
        print "Fichier introuvable"
        if not confirm("Créer fichier ?"):
            return
354
355
356
        annotations += u"""Ceci est un fichier initial contenant un mot de passe
aléatoire, pensez à rajouter une ligne "login: ${login}" """
        texte = "pass: %s\n" % gen_password()
357
358
359
        roles = get_my_roles()
        # Par défaut les roles d'un fichier sont ceux en écriture de son
        # créateur
360
        roles = [ r[:-2] for r in roles if r.endswith('-w') ]
361
362
363
364
        if roles == []:
            print "Vous ne possédez aucun rôle en écriture ! Abandon."
            return
        value = {'roles':roles}
Daniel STAN's avatar
Daniel STAN committed
365
366
367
368
369
    else:
        (sin,sout) = gpg('decrypt')
        sin.write(value['contents'])
        sin.close()
        texte = sout.read()
370
371
    value['roles'] = NROLES or value['roles']

372
    annotations += u"Ce fichier sera chiffré pour les rôles suivants :\n%s\n\
373
374
375
376
377
378
379
C'est-à-dire pour les utilisateurs suivants :\n%s" % (
           ', '.join(value['roles']),
           '\n'.join(' %s' % rec for rec in get_dest_of_roles(value['roles']))
        )
        
    ntexte = editor(texte, annotations)

380
    if ntexte == None and not nfile and NROLES == None:
Daniel STAN's avatar
Daniel STAN committed
381
382
        print "Pas de modifications effectuées"
    else:
Daniel STAN's avatar
Daniel STAN committed
383
        ntexte = texte if ntexte == None else ntexte
Daniel STAN's avatar
Daniel STAN committed
384
385
386
387
        if put_password(fname,value['roles'],ntexte):
            print "Modifications enregistrées"
        else:
            print "Erreur lors de l'enregistrement (avez-vous les droits suffisants ?)"
Daniel STAN's avatar
Daniel STAN committed
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412

def confirm(text):
    if FORCED: return True
    while True:
        out = raw_input(text + ' (O/N)').lower()
        if out == 'o':
            return True
        elif out == 'n':
            return False

def remove_file(fname):
    if not confirm('Êtes-vous sûr de vouloir supprimer %s ?' % fname):
        return
    if rm_file(fname):
        print "Suppression achevée"
    else:
        print "Erreur de suppression (avez-vous les droits ?)"
    

def my_check_keys():
    check_keys() and "Base de clés ok" or "Erreurs dans la base"

def my_update_keys():
    print update_keys()

Daniel STAN's avatar
Daniel STAN committed
413
414
def update_role():
    roles = None
Daniel STAN's avatar
Daniel STAN committed
415
416
417
418
419
    """ Reencode les fichiers, si roles est fourni,
    contient une liste de rôles"""
    my_roles = get_my_roles()
    if roles == None:
        # On ne conserve que les rôles qui finissent par -w
420
        roles = [ r[:-2] for r in my_roles if r.endswith('-w')]
Daniel STAN's avatar
Daniel STAN committed
421
422
423
424
425
426
427
428
429
430
431
432
433
    if type(roles) != list:
        roles = [roles]

    for (fname,froles) in all_files().iteritems():
        if set(roles).intersection(froles) == set([]):
            continue
        #if VERB:
        print "Reencodage de %s" % fname
        put_password(fname,froles,get_password(fname))

def parse_roles(strroles):
    if strroles == None: return None
    roles = all_roles()
434
    my_roles = filter(lambda r: SERVER['user'] in roles[r],roles.keys())
435
    my_roles_w = [ r[:-2] for r in my_roles if r.endswith('-w') ]
Daniel STAN's avatar
Daniel STAN committed
436
437
438
439
440
441
442
443
444
445
446
447
448
449
    ret = set()
    writable = False
    for role in strroles.split(','):
        if role not in roles.keys():
            print("Le rôle %s n'existe pas !" % role)
            return False
        if role.endswith('-w'):
            print("Le rôle %s ne devrait pas être utilisé ! (utilisez %s)"
                % (role,role[:-2]))
            return False
        writable = writable or role in my_roles_w
        ret.add(role)
    
    if not FORCED and not writable:
Daniel STAN's avatar
Daniel STAN committed
450
451
452
        if not confirm("""Vous vous apprêtez à perdre vos droits d'écritures\
(role ne contient pas %s) sur ce fichier, continuer ?""" % \
            ", ".join(my_roles_w)):
Daniel STAN's avatar
Daniel STAN committed
453
454
            return False
    return list(ret)
Daniel STAN's avatar
Daniel STAN committed
455
456

if __name__ == "__main__":
Daniel STAN's avatar
Daniel STAN committed
457
    parser = argparse.ArgumentParser(description="trousseau crans")
458
459
    parser.add_argument('--server',default='default',
        help='Utilisation d\'un serveur alternatif (test, etc)')
Daniel STAN's avatar
Daniel STAN committed
460
461
    parser.add_argument('-v','--verbose',action='store_true',default=False,
        help="Mode verbeux")
Daniel STAN's avatar
Daniel STAN committed
462
    parser.add_argument('-c','--clipboard',action='store_true',default=None,
Daniel STAN's avatar
Daniel STAN committed
463
        help="Stocker le mot de passe dans le presse papier")
Daniel STAN's avatar
Daniel STAN committed
464
465
466
    parser.add_argument('--noclipboard',action='store_false',default=None,
        dest='clipboard',
        help="Ne PAS stocker le mot de passe dans le presse papier")
Daniel STAN's avatar
Daniel STAN committed
467
468
469
470
471
472
473
    parser.add_argument('-f','--force',action='store_true',default=False,
        help="Forcer l'action")

    # Actions possibles
    action_grp = parser.add_mutually_exclusive_group(required=False)
    action_grp.add_argument('--edit',action='store_const',dest='action',
        default=show_file,const=edit_file,
Daniel STAN's avatar
Daniel STAN committed
474
        help="Editer (ou créer)")
Daniel STAN's avatar
Daniel STAN committed
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
    action_grp.add_argument('--view',action='store_const',dest='action',
        default=show_file,const=show_file,
        help="Voir")
    action_grp.add_argument('--remove',action='store_const',dest='action',
        default=show_file,const=remove_file,
        help="Effacer")
    action_grp.add_argument('-l','--list',action='store_const',dest='action',
        default=show_file,const=show_files,
        help="Lister les fichiers")
    action_grp.add_argument('--check-keys',action='store_const',dest='action',
        default=show_file,const=my_check_keys,
        help="Vérifier les clés")
    action_grp.add_argument('--update-keys',action='store_const',dest='action',
        default=show_file,const=my_update_keys,
        help="Mettre à jour les clés")
    action_grp.add_argument('--list-roles',action='store_const',dest='action',
        default=show_file,const=show_roles,
        help="Lister les rôles des gens")
493
494
495
    action_grp.add_argument('--list-servers',action='store_const',dest='action',
        default=show_file,const=show_servers,
        help="Lister les rôles serveurs")
Daniel STAN's avatar
Daniel STAN committed
496
497
498
499
500
501
502
503
504
505
    action_grp.add_argument('--recrypt-role',action='store_const',dest='action',
        default=show_file,const=update_role,
        help="Met à jour (reencode les roles)")

    parser.add_argument('--roles',nargs='?',default=None,
        help="liste des roles à affecter au fichier")
    parser.add_argument('fname',nargs='?',default=None,
        help="Nom du fichier à afficher")

    parsed = parser.parse_args(sys.argv[1:])
506
    SERVER = config.servers[parsed.server]
Daniel STAN's avatar
Daniel STAN committed
507
    VERB = parsed.verbose
508
    DEBUG = VERB
Daniel STAN's avatar
Daniel STAN committed
509
510
    if parsed.clipboard != None:
        CLIPBOARD = parsed.clipboard
Daniel STAN's avatar
Daniel STAN committed
511
512
513
514
515
516
517
518
519
    FORCED = parsed.force
    NROLES = parse_roles(parsed.roles)

    if NROLES != False:
        if parsed.action.func_code.co_argcount == 0:
            parsed.action()
        elif parsed.fname == None:
            print("Vous devez fournir un nom de fichier avec cette commande")
            parser.print_help()
Daniel STAN's avatar
Daniel STAN committed
520
        else:
Daniel STAN's avatar
Daniel STAN committed
521
            parsed.action(parsed.fname)
522
523
    
    saveclipboard(restore=True)
Daniel STAN's avatar
init  
Daniel STAN committed
524