File: key-manager.py.in

package info (click to toggle)
bacula 15.0.3-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 29,780 kB
  • sloc: ansic: 194,276; cpp: 41,177; sh: 28,258; python: 6,669; makefile: 5,275; perl: 3,666; sql: 1,371; java: 345; xml: 196; awk: 51; sed: 25
file content (521 lines) | stat: -rw-r--r-- 21,601 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
#   Bacula(R) - The Network Backup Solution
#
#
# This script is a simple key-manager for the Volume Encryption done by the
# Storage Daemon.
#
# One key is automatically generated at LABEL time for every VOLUME and
# is stored in the "KEY_DIR" directory.
#
# The keys are a random sequence of bytes as generated by /dev/urandom.
#
# Two encryption methods are available: AES_128_XTS & AES_256_XTS
#
# A third encryption method, called "NULL", exists for testing purposes only.
#
# The main purpose of this script is to provide an example and illustrate the
# protocol between the key-manager and the Storage Daemon. It may be used in
# a production environment.
#
# Use --help to get help the command line parameters of this script.
# If you modify this script, rename it to avoid the possibility of this script
# being overwritten during an upgrade.
#
# The script gets its input from environment variables and returns its
# output via STDOUT.
#
# The Storage Daemon passes the following environment variables:
#
# - OPERATION: This is can "LABEL" when the volume is labeled. In this case
#    the script should generate a new key for the volume. This variable can
#    also be "READ" when the volume has already been labeled and the Storage
#    Daemon needs the already existing key to read or append data to the volume.
#
# - VOLUME_NAME: This is the name of the volume.
#
# Some variables already exist to support a "Master Key" in the future.
# This feature is not yet supported, but will come later:
#
# - ENC_CIPHER_KEY: This is a base64 encoded version of the key encrypted by
#    the "master key"
#
# - MASTER_KEYID: This is a base64 encoded version of the Key Id of
#    the "master key" that was used to encrypt the ENC_CIPHER_KEY value above.
#
# The Storage Daemon expects some values in return via STDOUT:
#
# - volumename: This is a repetition of the name of the volume that is
#    given to the script. This field is optional and ignored by Bacula.
#
# - cipher: This is the cipher that the Storage Daemon must use.
#    The Storage Daemon knows the following ciphers: AES_128_XTS and AES_256_XTS.
#    Of course the key lengths vary with the cipher.
#
# - cipher_key: This is the symmetric key in base64 format.
#
# - comment: This is a single line of text that is optional and ignored
#    by the SD.
#
# - error: This is a single line error message.
#    This is optional, but when provided, the SD considers that the script
#    returned an error and will display this error in the job log.
#
# The Storage Daemon expects an exit code of 0. If the script exits with a
# different error code, any output is ignored and the Storage Daemon will
# display a generic message with the exit code in the job log.
#
# To return an error to the Storage Daemon, the script must set the "error"
# variable string and return an error code of 0.
#
# Here are some input/output samples to illustrate the script's funtion:
#
#   $ OPERATION=LABEL VOLUME_NAME=Volume0001 ./key-manager.py getkey --cipher AES_128_XTS --key-dir tmp/keys 
#   cipher: AES_128_XTS
#   cipher_key: G6HksAYDnNGr67AAx2Lb/vecTVjZoYAqSLZ7lGMyDVE=
#   volume_name: Volume0001
#
#   $ OPERATION=READ VOLUME_NAME=Volume0001 ./key-manager.py getkey --cipher AES_128_XTS --key-dir tmp/keys 
#   cipher: AES_128_XTS
#   cipher_key: G6HksAYDnNGr67AAx2Lb/vecTVjZoYAqSLZ7lGMyDVE=
#   volume_name: Volume0001
#
#   $ cat tmp/keys/Volume0001 
#   cipher: AES_128_XTS
#   cipher_key: G6HksAYDnNGr67AAx2Lb/vecTVjZoYAqSLZ7lGMyDVE=
#   volume_name: Volume0001
#
#   $ OPERATION=READ VOLUME_NAME=MissingVol ./key-manager.py getkey --cipher AES_128_XTS --key-dir tmp/keys 2>/dev/null
#   error: no key information for volume "MissingVol"
#   $ echo $?
#   0
#
#   $ OPERATION=BAD_CMD VOLUME_NAME=Volume0002 ./key-manager.py getkey --cipher AES_128_XTS --key-dir tmp/keys 2>/dev/null
#   error: environment variable OPERATION invalid "BAD_CMD" for volume "Volume0002"
#   $ echo $?
#   0
#
# ------------
# BEGIN SCRIPT
# ------------
import sys
import logging
import argparse
import re
import os
import base64
import codecs
import random
import tempfile

if sys.version_info[0] < 3:
    # python 2.7
    import ConfigParser as configparser
else:
    # python >= 3.X
    import configparser

# logging.raiseExceptions=False

LOG_FILE="@working_dir@/key-manager.log"
KEY_DIR="@sysconfdir@/keydir"
CONFIG_FILE="@sysconfdir@/key-manager.conf"
GNUPGHOME="@sysconfdir@/gnupg"

# trick to use the .in as a python script
if LOG_FILE.startswith('@'):
    LOG_FILE=os.path.join(tempfile.gettempdir(), 'key-manager.log')
if KEY_DIR.startswith('@'):
    KEY_DIR=os.path.join(tempfile.gettempdir(), 'keydir')
if CONFIG_FILE.startswith('@'):
    CONFIG_FILE=os.path.join(tempfile.gettempdir(), 'key-manager.conf')
if GNUPGHOME.startswith('@'):
    GNUPGHOME=os.path.join(tempfile.gettempdir(), 'gnupg')

MASTER_KEYID_SIZE=20
want_to_have_all_the_same_keys=False
#want_to_have_all_the_same_keys=True
CIPHERS=[ 'NULL', 'AES_128_XTS', 'AES_256_XTS' ]
DEFAULT_CIPHER=CIPHERS[1]
MAX_NAME_LENGTH=128
volume_re=re.compile('[A-Za-z0-9:.-_]{1,128}')

# the config from the configuration file if any
config=None

class CryptoCtx:
    master_key_id=None
    cipher=DEFAULT_CIPHER
    stealth=False
    passphrase=None

# raiseExceptions=False
class MyFileHandler(logging.FileHandler):
    """raise an exception when the format don't match the parameters
       instead of printing an error on stderr
    """
    def emit(self, record):
        """dont use try/except and dont call handleError"""
        try:
            msg = self.format(record)
            stream = self.stream
            stream.write(msg)
            stream.write(self.terminator)
            self.flush()
        except Exception:
            if False:
                self.handleError(record)
            else:
                raise

def escape_volume_name(name):
    escapechar='='
    replace_esc='{}0x{:02x}'.format(escapechar, ord(escapechar))
    replace_colon='{}0x{:02x}'.format(escapechar, ord(':'))
    newname=name.replace(escapechar, replace_esc)
    newname=newname.replace(':', replace_colon)
    return newname

def add_console_logger():
    console=logging.StreamHandler()
    console.setFormatter(logging.Formatter('%(levelname)-3.3s %(filename)s:%(lineno)d %(message)s', '%H:%M:%S'))
    console.setLevel(logging.INFO) # must be INFO for prod
    logging.getLogger().addHandler(console)
    return console

def add_file_logger(filename):
    filelog=logging.FileHandler(filename)
    # %(asctime)s  '%Y-%m-%d %H:%M:%S'
    filelog.setFormatter(logging.Formatter('%(asctime)s %(levelname)-3.3s %(filename)s:%(lineno)d %(message)s', '%H:%M:%S'))
    filelog.setLevel(logging.INFO)
    logging.getLogger().addHandler(filelog)
    return filelog

def volume_regex_type(arg_value):
    if not volume_re.match(arg_value):
        raise argparse.ArgumentTypeError
    return arg_value

def setup_logging(debug, verbose, logfile):
    level=logging.WARNING
    if debug:
        level=logging.DEBUG
    elif verbose:
        level=logging.INFO

    logging.getLogger().setLevel(level)

    if logfile:
        filelog=MyFileHandler(logfile)
        # %(asctime)s  '%Y-%m-%d %H:%M:%S'
        filelog.setFormatter(logging.Formatter('%(asctime)s %(levelname)-3.3s %(filename)s:%(lineno)d %(message)s', '%Y-%m-%d %H:%M:%S'))
        filelog.setLevel(level)
        logging.getLogger().addHandler(filelog)
    else:
        console=logging.StreamHandler()
        console.setFormatter(logging.Formatter('%(levelname)-3.3s %(filename)s:%(lineno)d %(message)s', '%H:%M:%S'))
        console.setLevel(level)
        logging.getLogger().addHandler(console)

def test(args):
    assert 'hello123'==escape_volume_name('hello123')
    assert 'vol=0x3dname=0x3a.-_end'==escape_volume_name('vol=name:.-_end')

def bytes_xor(data, key):
    """trivial encode and decode function"""
    enc=[]
    for i, ch in enumerate(data):
        enc.append(ch ^ key[i%len(key)])
    return bytes(enc)

def check_force_cipher_env(cipher):
    return os.getenv('FORCE_CIPHER', cipher)

def get_crypto_ctx_from_config(args, volume_name, master_keyid=None):
    """ retrieve the master-key defined in the config file or a default CTX
     return
            None : for error
            MasterKey object : the master-key or a default context if no config
    """

    if args.config:
        config=configparser.ConfigParser()
        try:
            config.read(args.config)
        except configparser.ParsingError as exc:
            logging.error("parsing configuration file \"%s\": %s", args.config, str(exc))
            print('error: parsing configuration file \"{}\"\n'.format(args.config))
            return None
        the_section=None
        if master_keyid:
            if config.has_section(master_keyid):
                the_section=master_keyid
            else:
                logging.error("configuration file \"%s\" has no master-key \"%s\"", args.config, master_keyid)
                print('error: configuration file \"{}\" has no master-key \"{}\"\n'.format(args.config, master_keyid))
                return None
        else:
            # search for the section matching the volume
            for section in config.sections():
                try:
                    volume_regex=config.get(section, 'volume_regex')
                except configparser.NoOptionError:
                    logging.debug("ignore section \"%s\"", section)
                    continue
                try:
                    match=re.match(volume_regex, volume_name)
                except re.error:
                    logging.error("regular expression error in configuration file \"%s\" in section \"%s\" : %s", args.config, section, str(exc))
                    print("error: regular expression error in configuration file \"{}\" in section \"{}\" : {}".format(args.config, section, str(exc)))
                    return None
                if match:
                    the_section=section
                    break
            if not the_section:
                logging.debug("no master-key defined for volume \"%s\"", volume_name)

        crypto_ctx=CryptoCtx()
        if the_section==None:
            # no master key
            crypto_ctx.master_key_id=None
            crypto_ctx.cipher=args.cipher
        else:
            crypto_ctx.master_key_id=the_section
            try:
                crypto_ctx.gnupghome=config.get(the_section, 'gnupghome')
                if crypto_ctx.gnupghome.startswith('"') and crypto_ctx.gnupghome.endswith('"'):
                    crypto_ctx.gnupghome=crypto_ctx.gnupghome[1:-1]
            except configparser.NoOptionError:
                crypto_ctx.gnupghome=GNUPGHOME
            try:
                crypto_ctx.cipher=config.get(the_section, 'cipher')
            except configparser.NoOptionError:
                crypto_ctx.cipher=args.cipher
            try:
                crypto_ctx.stealth=config.getboolean(the_section, 'stealth')
            except configparser.NoOptionError:
                pass
            try:
                crypto_ctx.passphrase=config.get(the_section, 'passphrase')
            except configparser.NoOptionError:
                pass
            logging.info("use masterkey %r and cipher \"%s\" for volume \"%s\"", crypto_ctx.master_key_id, crypto_ctx.cipher, volume_name)
    else:
        crypto_ctx=CryptoCtx()
        crypto_ctx.cipher=args.cipher

    return crypto_ctx

def generate_key(crypto_ctx, volume_name):
    if crypto_ctx.cipher=='AES_128_XTS':
        key_size=32
    elif crypto_ctx.cipher=='AES_256_XTS':
        key_size=64
    elif crypto_ctx.cipher=='NULL':
        key_size=16
    else:
        logging.error('unknown cipher %s', crypto_ctx.cipher)
        print('error: unknown cipher %s'.format(crypto_ctx.cipher))
        return None # unknown cipher
    urandom=open('/dev/urandom', 'rb')
    key=urandom.read(key_size)
    if want_to_have_all_the_same_keys:
        key=b'A'*key_size
    key_base64=codecs.decode(base64.b64encode(key))
    r=dict()
    r['cipher']=crypto_ctx.cipher
    r['cipher_key']=key_base64
    r['volume_name']=volume_name
    if crypto_ctx.master_key_id:
        try:
            import gnupg
            gnupg.GPG   # check that we have the module and not the GnuPG directory
        except (ImportError, AttributeError):
            logging.error('module gnupg is not installed')
            print('error: python module gnupg is not installed')
            return None
        gpg=gnupg.GPG(gnupghome=crypto_ctx.gnupghome)
        master_keyid_base64=codecs.decode(base64.b64encode(codecs.encode(crypto_ctx.master_key_id)))
        r['master_keyid']=master_keyid_base64
        enc_key=gpg.encrypt(key, crypto_ctx.master_key_id, armor=False)
        enc_key_base64=codecs.decode(base64.b64encode(enc_key.data))
        r['enc_cipher_key']=enc_key_base64
    return r

def decrypt_key(crypto_ctx, volume_name, enc_cipher_key):
    try:
        import gnupg
        gnupg.GPG   # check that we have the module and not the GnuPG directory
    except (ImportError, AttributeError):
        logging.error('module gnupg is not installed')
        print('error: python module gnupg is not installed')
        return None
    r=dict()
    r['cipher']=crypto_ctx.cipher
    gpg=gnupg.GPG(gnupghome=crypto_ctx.gnupghome)
    master_keyid_base64=codecs.decode(base64.b64encode(codecs.encode(crypto_ctx.master_key_id)))
    r['master_keyid']=master_keyid_base64
    passphrase=crypto_ctx.passphrase
    cipher_key=gpg.decrypt(enc_cipher_key, passphrase=passphrase)
    if cipher_key.ok==False:
        logging.error('decryption error for volume "{}":'.format(volume_name, cipher_key.status))
        print('error: decryption error for volume "{}":'.format(volume_name, cipher_key.status))
        return None
    cipher_key_base64=codecs.decode(base64.b64encode(cipher_key.data))
    r['cipher_key']=cipher_key_base64
    r['volume_name']=volume_name
    return r

def decode_data(data):
    d=dict()
    for line in data.split('\n'):
        if line:
            k, v=line.split(':', 1)
            d[k.strip()]=v.strip()
    return d

def encode_data(dct, exclude=None):
    lines=[]
    for key, value in dct.items():
        if not exclude or not key in exclude:
            lines.append('{}: {}'.format(key, value))
    lines.append('')
    return '\n'.join(lines)

def getkey0(args):
    operation=os.getenv('OPERATION')
    volume_name=os.getenv('VOLUME_NAME')
    if not volume_name:
        logging.error("environment variable VOLUME_NAME missing or empty")
        print('error: environment variable VOLUME_NAME missing or empty\n')
        return 0
    if not operation:
        logging.error("environment variable OPERATION missing or empty")
        print('error: environment variable OPERATION missing or empty\n')
        return 0
    if not operation in [ 'LABEL', 'READ']:
        logging.error("environment variable OPERATION invalid \"%s\" for volume \"%s\"", operation, volume_name)
        print("error: environment variable OPERATION invalid \"{}\" for volume \"{}\"\n".format(operation, volume_name))
        return 0

    enc_cipher_key=os.getenv('ENC_CIPHER_KEY')
    master_keyid=os.getenv('MASTER_KEYID')
    logging.info('getkey OPERATION="%s" VOLUME_NAME="%s"%s%s', operation, volume_name, ' ENC_CIPHER_KEY="{}"'.format(enc_cipher_key) if enc_cipher_key else "", ' MASTER_KEYID="{}"'.format(master_keyid) if master_keyid else "")
    key_filename=os.path.join(args.key_dir, escape_volume_name(volume_name))
    if operation=='LABEL':
        crypto_ctx=get_crypto_ctx_from_config(args, volume_name)
        if crypto_ctx==None:
            return 0 # error reading the config file
        crypto_ctx.cipher=check_force_cipher_env(crypto_ctx.cipher)
        if os.path.isfile(key_filename):
            logging.info("delete old keyfile for volume \"%s\" : %s", volume_name, key_filename)
            os.unlink(key_filename)
        ctx=generate_key(crypto_ctx, volume_name)
        if ctx==None:
            return 0 # error while generating the key (wrong cipher or gnupg not installed)
        logging.info("generate key volume=%s cipher=%s enckey=%s masterkey=%s", ctx['volume_name'], ctx['cipher'], ctx.get('enc_cipher_key', ''), ctx.get('master_keyid', ''))
        if crypto_ctx.stealth:
            # don't keep an un-encrypted version of the cipher_key
            # use the masterkey id to decrypte the enckey
            exclude=set(['cipher_key'])
        else:
            exclude=set()
        data=encode_data(ctx, exclude=exclude)
        f=open(key_filename, 'wt')
        f.write(data)
        f.close()
        output=encode_data(ctx) # including the 'cipher_key'
    elif operation=='READ':
        ctx=dict()
        if os.path.isfile(key_filename):
            # use data in the key file
            data=open(key_filename, 'rt').read()
            ctx=decode_data(data)
        if 'cipher_key' in ctx:
            logging.info("read key volume=%s cipher=%s", ctx['volume_name'], ctx['cipher'])
            output=encode_data(ctx)
        elif not enc_cipher_key:
            logging.error("no cipher key nor encrypted cipher key for volume \"%s\"", volume_name)
            print('error: no cipher key nor encrypted cipher key for volume "{}"'.format(volume_name))
            return 0
        else:
            enc_cipher_key_raw=base64.b64decode(codecs.encode(enc_cipher_key))
            master_keyid_raw=base64.b64decode(codecs.encode(master_keyid))
            master_keyid_ascii=codecs.decode(master_keyid_raw)
            # maybe we can retrieve the passphrase for the master-key
            crypto_ctx=get_crypto_ctx_from_config(args, volume_name, master_keyid_ascii)
            if crypto_ctx==None:
                return 0 # error no master-key
                # maybe the master_keyid from the volume could have done the job
                # if gnupg still remember this master-key despit it has been
                # removed from the key-manager config file
            crypto_ctx.cipher=check_force_cipher_env(crypto_ctx.cipher)
            # use the master-key to decrypt the enc_cipher_key
            ctx=decrypt_key(crypto_ctx, volume_name, enc_cipher_key_raw)
            if ctx==None:
                return 0 # error decrypting key
            logging.info("read key volume=%s cipher=%s cipher_key=%s masterkey=%s", ctx['volume_name'], ctx['cipher'], ctx['cipher_key'], ctx['master_keyid'])
            output=encode_data(ctx)
    else:
        output='error: unknown operation \"%r\"'.format(operation)
    print(output)
    return 0

def getkey(args):
    try:
        getkey0(args)
    except:
        logging.exception("unhandled exception in getkey0")
        sys.exit(1)

mainparser=argparse.ArgumentParser(description='Bacula Storage Daemon key manager ')
subparsers=mainparser.add_subparsers(dest='command', metavar='', title='valid commands')

common_parser=argparse.ArgumentParser(add_help=False)
common_parser.add_argument('--key-dir', '-k', metavar='DIRECTORY', type=str, default=KEY_DIR, help='the directory where to store the keys')
common_parser.add_argument('--config', '-C', metavar='CONFIG', type=str, help='the configuration file')
common_parser.add_argument('--log', metavar='LOGFILE', type=str, default=LOG_FILE, help='setup the logfile')
common_parser.add_argument('--debug', '-d', action='store_true', help='enable debugging')
common_parser.add_argument('--verbose', '-v', action='store_true', help='be verbose')

parser=subparsers.add_parser('getkey', description="Retrieve a key", parents=[common_parser, ],
    help="retrieve a key or generate one if don't exist yet")
parser.add_argument('--cipher', '-c', metavar='CIPHER', choices=CIPHERS, default=DEFAULT_CIPHER, help='set the default cipher in {}'.format(', '.join(CIPHERS)))
parser.set_defaults(func=getkey)

parser=subparsers.add_parser('test', description="Run some internal test of the code")
parser.set_defaults(func=test)

args=mainparser.parse_args()
args._parser=mainparser

setup_logging(getattr(args, 'debug', None), getattr(args, 'verbose', None), getattr(args, 'log', None))

# check for the key_dir directory
if hasattr(args, 'key_dir'):
    if not os.path.exists(args.key_dir):
        try:
            os.makedirs(args.key_dir, 0o700)
        except:
            logging.error('Cannot create the "key" directory %s', args.key_dir)
        else:
            logging.error('The "key" directory don\'t exists. Create directory %s', args.key_dir)
    if not os.path.isdir(args.key_dir):
        logging.error('The "key" directory don\'t exists: %s', args.key_dir)
        mainparser.error('error: path "{}" is not a directory'.format(args.key_dir))
    if not os.access(args.key_dir, os.R_OK|os.W_OK):
        logging.error('The "key" directory is not accessible for READ and WRITE: %s', args.key_dir)
        mainparser.error('error: need read and write access to "{}"'.format(args.key_dir))

# check for the config file
if hasattr(args, 'config'):
    if args.config==None and os.path.exists(CONFIG_FILE):
        args.config=CONFIG_FILE # the default file exists, use it
        logging.debug('Use config file %s', args.config)
    if args.config!=None and (not os.path.exists(args.config) or not os.access(args.config, os.R_OK)):
        logging.error('The config file don\'t exists or cannot be read: %s', args.config)
        mainparser.error('The config file don\'t exists or cannot be read: %s'.format(args.config))

sys.exit(args.func(args))