#!/usr/bin/python3 """ Scan through the various HTCondor signing keys and passwords, looking for NULL characters that potentially truncated keys in earlier versions of HTCondor. """ import argparse import os import pathlib import re import sys import tempfile import htcondor MINIMUM_SAFE_LENGTH = 12 g_color_output = os.isatty(sys.stdout.fileno()) def format_red_terminal_text(text): if g_color_output: return "\033[1m\033[91m%s\033[0m" % text return text def simple_scramble(in_buf): """ Undo the simple scramble of HTCondor - simply XOR with 0xdeadbeef """ deadbeef = [0xde, 0xad, 0xbe, 0xef] out_buf = b'' for idx in range(len(in_buf)): scramble = in_buf[idx] ^ deadbeef[idx % 4] out_buf += b'%c' % scramble return out_buf def truncate_at_null(in_buf): """ Determine whether the input buffer contains a NULL character """ for idx, byteval in zip(range(len(in_buf)), in_buf): if byteval == 0: return True, in_buf[:idx] return False, in_buf def atomic_write_contents(dest, contents): """ Atomically write `contents` to Path `dest` and fsync the result. """ with tempfile.NamedTemporaryFile(mode="w+b", prefix=".condor." + dest.name, dir=dest.parent, delete=False) as tf: tf.write(contents) tf.close() os.rename(tf.name, str(dest)) with open(str(dest), "r") as fp: os.fsync(fp) def truncate_keyfile(key, contents, truncated_key): """ Truncate a given key file according to the pre-8.9.12 logic """ try: new_fname = key.with_name(".condor.backup." + key.name) if new_fname.exists(): print("Backup file %s already exists; not overwriting." % str(new_fname)) sys.exit(4) atomic_write_contents(new_fname, contents) atomic_write_contents(key, simple_scramble(truncated_key)) except Exception as exc: print("Failure when truncating %s: %s" % (str(key), str(exc))) raise sys.exit(3) def enumerate_keys(): """ Enumerate all possible keys that HTCondor might use. """ keys = set() if htcondor.param.get("SEC_TOKEN_POOL_SIGNING_KEY_FILE"): keys.add(pathlib.Path(htcondor.param['SEC_TOKEN_POOL_SIGNING_KEY_FILE'])) keys.add(pathlib.Path(htcondor.param['SEC_PASSWORD_FILE'])) exclude_regexp = re.compile(htcondor.param['LOCAL_CONFIG_DIR_EXCLUDE_REGEXP']) password_directory = pathlib.Path(htcondor.param['SEC_PASSWORD_DIRECTORY']) skipped_files = 0 for fname in os.listdir(str(password_directory)): if exclude_regexp.match(fname): print("Skipping excluded file %s." % fname) skipped_files += 1 continue keys.add(password_directory / fname) if skipped_files: print("Skipped %d excluded files.\n" % skipped_files) return keys def main(): parser = argparse.ArgumentParser(description="Examine HTCondor key files, looking for keys that may be silently truncated in prior HTCondor versions") parser.add_argument("key", nargs="*", help="Specific key files to examine; if not given, then the defaults are used from the HTCondor configuration") parser.add_argument("--truncate", default=False, help="When a potentially insecure key is encountered, truncate it to match the behavior prior to 8.9.12", action="store_true") args = parser.parse_args() keys_to_check = [pathlib.Path(fname) for fname in args.key] if not keys_to_check: try: keys_to_check = enumerate_keys() except PermissionError as pe: print("Cannot enumerate default keys: %s" % str(pe)) sys.exit(2) good_key_count = 0 keys_to_check = list(keys_to_check) keys_to_check.sort() truncated_key_count = 0 short_key_count = 0 if not keys_to_check: print("There are no keys to check") sys.exit(0) print("Key tests:") for key in keys_to_check: try: contents = key.read_bytes() except OSError as oe: print("%s: Unable to read - %s" % (str(key), oe)) continue is_truncated, truncated_key = truncate_at_null(contents) unscrambled = simple_scramble(truncated_key) is_unscrambled_truncated, truncated_key = truncate_at_null(unscrambled) if is_truncated or is_unscrambled_truncated: truncated_key_count += 1 if len(truncated_key) < MINIMUM_SAFE_LENGTH: short_key_count += 1 if is_truncated and not is_unscrambled_truncated: if args.truncate: truncate_keyfile(key, contents, truncated_key) print("%s %s: Scrambled key was truncated; new size is %d bytes (originally %d bytes)" % (format_red_terminal_text("TRUNCATED"), str(key), len(truncated_key), len(contents))) else: print("%s %s: Scrambled key will be silently truncated; effective size is %d bytes (originally %d bytes)" % (format_red_terminal_text("FAIL"), str(key), len(truncated_key), len(contents))) elif is_unscrambled_truncated and not is_truncated: if args.truncate: truncate_keyfile(key, contents, truncated_key) print("%s %s: Key was truncated; new size is %d bytes (originally %d bytes)" % (format_red_terminal_text("TRUNCATED"), str(key), len(truncated_key), len(contents))) else: print("%s %s: Key will be silently truncated; effective size is %d bytes (originally %d bytes)" % (format_red_terminal_text("FAIL"), str(key), len(truncated_key), len(contents))) elif is_unscrambled_truncated and is_truncated: if args.truncate: truncate_keyfile(key, contents, truncated_key) print("%s %s: Scrambled key and key were truncated; new size is %d bytes (originally %d bytes)" % (format_red_terminal_text("TRUNCATED"), str(key), len(truncated_key), len(contents))) else: print("%s %s: Scrambled key and key will be silently truncated; effective size is %d bytes (originally %d bytes)" % (format_red_terminal_text("FAIL"), str(key), len(truncated_key), len(contents))) elif len(truncated_key) < MINIMUM_SAFE_LENGTH: print("%s %s: Key is too short: %d bytes" % (format_red_terminal_text("FAIL"), str(key), len(truncated_key))) else: print("OK %s" % str(key)) good_key_count += 1 print("") print("There were %d compatible keys and %d keys with issues." % (good_key_count, len(keys_to_check) - good_key_count)) if good_key_count != len(keys_to_check): print("") if truncated_key_count and not args.truncate: print("Some keys have compatibility problems with clients or servers older than HTCondor 8.9.12.") print("Before HTCondor 8.9.12, these keys were silently truncated, potentially resulting in insecure setups") print("Rerun this tool with the `--truncate` flag to change the files on disk and restore compatibbility with older HTCondor versions") elif args.truncate: print("There were %d keys that were explicitly truncated" % truncated_key_count) if short_key_count: print("HTCondor recommends passwords be longer than %d characters." % MINIMUM_SAFE_LENGTH) sys.exit(1) if __name__ == '__main__': main()