--- /dev/null
+#!/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))
+
+