From: Alain Spineux Date: Thu, 13 Oct 2022 09:52:05 +0000 (+0200) Subject: add the script key-manager.py X-Git-Tag: Beta-15.0.0~401 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=cf80038f856a7f29d2ed593479c2b9dcbe888820;p=thirdparty%2Fbacula.git add the script key-manager.py - environment variables are used to pass parameter to the script - NULL cipher for testing only --- diff --git a/bacula/autoconf/configure.in b/bacula/autoconf/configure.in index 1952e3198..de8ee9150 100644 --- a/bacula/autoconf/configure.in +++ b/bacula/autoconf/configure.in @@ -4140,6 +4140,7 @@ AC_CONFIG_FILES([ scripts/logrotate \ scripts/mtx-changer \ scripts/disk-changer \ + scripts/key-manager.py \ scripts/logwatch/Makefile \ scripts/logwatch/logfile.bacula.conf \ scripts/bat.desktop \ @@ -4286,7 +4287,7 @@ cd ${BUILD_DIR} cd scripts chmod 755 bacula btraceback mtx-changer -chmod 755 bconsole disk-changer devel_bacula logrotate +chmod 755 bconsole disk-changer devel_bacula logrotate key-manager.py cd .. c=updatedb diff --git a/bacula/scripts/Makefile.in b/bacula/scripts/Makefile.in index 4df1d7db0..cf15ec293 100755 --- a/bacula/scripts/Makefile.in +++ b/bacula/scripts/Makefile.in @@ -74,6 +74,13 @@ install: installdirs $(DESTDIR)$(scriptdir)/btraceback.dbx \ $(DESTDIR)$(scriptdir)/btraceback.mdb $(INSTALL_SCRIPT) btraceback $(DESTDIR)$(sbindir)/btraceback + @if test -f ${DESTDIR}${scriptdir}/key-manager.py; then \ + echo " ==> Saving existing key-manager.py to key-manager.py.old"; \ + $(MV) -f ${DESTDIR}${scriptdir}/key-manager.py ${DESTDIR}${scriptdir}/key-manager.py.old; \ + fi + $(INSTALL_SCRIPT) key-manager.py $(DESTDIR)$(scriptdir)/key-manager.py + + uninstall: @@ -126,7 +133,7 @@ Makefiles: $(SHELL) config.status chmod 755 bacula btraceback chmod 755 bacula-ctl-dir bacula-ctl-fd bacula-ctl-sd - chmod 755 mtx-changer bconsole tapealert + chmod 755 mtx-changer bconsole tapealert key-manager.py clean: @$(RMF) *~ 1 2 3 diff --git a/bacula/scripts/key-manager.py.in b/bacula/scripts/key-manager.py.in new file mode 100644 index 000000000..ced537c4e --- /dev/null +++ b/bacula/scripts/key-manager.py.in @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Bacula(R) - The Network Backup Solution + + Copyright (C) 2000-2023 Kern Sibbald + + The original author of Bacula is Kern Sibbald, with contributions + from many others, a complete list can be found in the file AUTHORS. + + You may use this file and others of this release according to the + license defined in the LICENSE file, which includes the Affero General + Public License, v3.0 ("AGPLv3") and some additional permissions and + terms pursuant to its AGPLv3 Section 7. + + This notice must be preserved when any source code is + conveyed and/or propagated. + + Bacula(R) is a registered trademark of Kern Sibbald. +# +# License: BSD 2-Clause; see file LICENSE-FOSS +# +# 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 +# stored into the "KEY_DIR" directory +# The key are random sequence of byt as generated by /dev/urandom. +# Two encryptions are available AES_128_XTS & AES_256_XTS +# A 3th encryption called NULL exist for testing purpose 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 can be used in production environment. +# Uses the --help to get help the command line parameters of this command. +# If you modify this script, rename it to avoid conflict with this file. +# +# The script get its input from the environment variables and return its output +# via STDOUT. +# +# The Storage Daemon passes the following variables via the *environment*: +# +# - OPERATION: This is can "LABEL" when the volume is labeled, in this case +# the script should generate a new key or this can be "READ" when +# the volume has already a label and the Storage Daemon need the already +# existing key to read or append data to the volume +# +# - VOLUME_NAME: This is the name of the volume +# +# Some variables are already there 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 Bacula must use. +# Bacula knows the following ciphers: AES_128_XTS and AES_256_XTS. +# Of course the key length vary with the cipher. +# +# - cipher_key: This is the symmetric key in base64 format. +# +# - comment: This is a single line of comment that is optional and ignored +# by Bacula. +# +# - error: This is a single line error message. +# This is optional, but when provided, Bacula consider that the script +# returned an error and display this error in the job log. +# +# Bacula expects an exit code of 0, if the script exits with a different +# error code, any output are ignored and Bacula display a generic message +# with the exit code in the job log. +# To return an error to bacula, the script must use the "error" field +# and return an error code of 0. +# +# Here are some input/output sample to illustrate the script +# $ OPERATION=LABEL VOLUME_NAME=Volume0001 ./sd_encryption_command.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 ./sd_encryption_command.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=DontExist ./sd_encryption_command.py getkey --cipher AES_128_XTS --key-dir tmp/keys 2>/dev/null +# error: no key information for volume "DontExist" +# $ echo $? +# 0 +# +# $ OPERATION=BAD_CMD VOLUME_NAME=Volume0002 ./sd_encryption_command.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 +# + +import sys +import logging +import argparse +import re +import os +import base64 +import codecs +import random + +# logging.raiseExceptions=False + +LOG_FILE="@working_dir@/key-manager.log" +KEY_DIR="@sysconfdir@/keydir" + +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}') + +# 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 generate_key(cipher, master_key): + if cipher=='AES_128_XTS': + key_size=32 + elif cipher=='AES_256_XTS': + key_size=64 + elif cipher=='NULL': + key_size=16 + else: + logging.error('unknown cipher %s', cipher) + return None # unknown key + 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']=cipher + r['cipher_key']=key_base64 + + if master_key: + master_keyid=urandom.read(MASTER_KEYID_SIZE) + if want_to_have_all_the_same_keys: + key=b'B'*MASTER_KEYID_SIZE + master_keyid_base64=codecs.decode(base64.b64encode(master_keyid)) + r['master_keyid']=master_keyid_base64 + enc_key=bytes_xor(key, master_keyid) + enc_key_base64=codecs.decode(base64.b64encode(enc_key)) + r['enc_cipher_key']=enc_key_base64 + + 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 1 + if not operation: + logging.error("environment variable OPERATION missing or empty") + print('error: environment variable OPERATION missing or empty\n') + return 1 + 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 1 + + enc_cipher_key=os.getenv('ENC_CIPHER_KEY') + master_keyid=os.getenv('MASTER_KEYID') + logging.info('getkey op=%s volume=%s enckey=%s masterkey=%s', operation, volume_name, enc_cipher_key if enc_cipher_key else "", master_keyid if master_keyid else "") + key_filename=os.path.join(args.key_dir, escape_volume_name(volume_name)) + if operation=='LABEL': + if os.path.isfile(key_filename): + logging.info("delete old keyfile for volume \"%s\" : ", volume_name, key_filename) + os.unlink(key_filename) + cipher=check_force_cipher_env(args.cipher) + ctx=generate_key(cipher, args.master_key) + if ctx==None: + return 1 + ctx['volume_name']=volume_name + logging.info("generate key volume=%s cipher=%s key=%s enckey=%s masterkey=%s", ctx['volume_name'], ctx['cipher'], ctx['cipher_key'], ctx.get('enc_cipher_key', ''), ctx.get('master_keyid', '')) + if args.master_key: + # 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': + if not os.path.isfile(key_filename): + logging.error("error: keyfile \"%s\" not found for volume \"%s\"", key_filename, volume_name) + print('error: no key information for volume "{}"'.format(volume_name)) + return 0 + data=open(key_filename, 'rt').read() + ctx=decode_data(data) + if args.master_key: + if not enc_cipher_key: + logging.warning("environment variable ENC_CIPHER_KEY missing or empty") + return 'error: environment variable ENC_CIPHER_KEY missing or empty\n' + if not master_keyid: + logging.warning("environment variable MASTER_KEYID missing or empty") + return 'error: environment variable MASTER_KEYID missing or empty\n' + if enc_cipher_key!=ctx['enc_cipher_key']: + logging.error("encoded cipher key for %s don't match, volume: %s, cache:%s", volume_name, enc_cipher_key, ctx['enc_cipher_key']) + if master_keyid!=ctx['master_keyid']: + logging.error("master_keyid for %s don't match, volume: %s, cache:%s", volume_name, enc_cipher_key, ctx['master_keyid']) + enc_cipher_key_raw=base64.b64decode(codecs.encode(enc_cipher_key)) + master_keyid_raw=base64.b64decode(codecs.encode(master_keyid)) + cipher_key_raw=bytes_xor(enc_cipher_key_raw, master_keyid_raw) + ctx['cipher_key']=codecs.decode(base64.b64encode(cipher_key_raw)) + logging.info("read key volume=%s cipher=%s key=%s enc_key=%s masterkey=%s", ctx['volume_name'], ctx['cipher'], ctx['cipher_key'], ctx['enc_cipher_key'], ctx['master_keyid']) + output=encode_data(ctx) + else: + logging.info("read key volume=%s cipher=%s key=%s", ctx['volume_name'], ctx['cipher'], ctx['cipher_key']) + 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('--log', metavar='LOGFILE', type=str, default=LOG_FILE, help='setup the logfile') +common_parser.add_argument('--debug', '-d', action='store_true', help='enable debuging') +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('--master-key', '-m', action='store_true', help='generate masterkey-id and encoded key for testing (not supported in 16.0)') +parser.add_argument('--cipher', '-c', metavar='KEY-TYPE', choices=CIPHERS, default=DEFAULT_CIPHER, help='the 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)) + +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)) + +sys.exit(args.func(args)) + +