]> git.ipfire.org Git - thirdparty/bacula.git/commitdiff
add the script key-manager.py
authorAlain Spineux <alain@baculasystems.com>
Thu, 13 Oct 2022 09:52:05 +0000 (11:52 +0200)
committerEric Bollengier <eric@baculasystems.com>
Thu, 14 Sep 2023 11:56:59 +0000 (13:56 +0200)
- environment variables are used to pass parameter to the script
- NULL cipher for testing only

bacula/autoconf/configure.in
bacula/scripts/Makefile.in
bacula/scripts/key-manager.py.in [new file with mode: 0644]

index 1952e3198bbfbb582b1bd88ece50f45210ced2cc..de8ee91500dbdc8c6f84f2b213ce5d183c3f3f6f 100644 (file)
@@ -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
index 4df1d7db0256c8237d43499f76f21d27f1fb47e3..cf15ec2934a3147638f560a9aa489fa17ef25209 100755 (executable)
@@ -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 (file)
index 0000000..ced537c
--- /dev/null
@@ -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 "<NONE>", master_keyid if master_keyid else "<NONE>")
+    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))
+
+