| #!/usr/bin/env python3 | |
| # Copyright 2021 The ChromiumOS Authors | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """A tool to manage the fingerprint study. | |
| # Dependencies | |
| | Software Needed | Gentoo/ChromiumOS-SDK Pkgs | Debian/gLinux Pkgs | | |
| | ----------------- | -------------------------- | ------------------ | | |
| | shred command | sys-apps/coreutils | coreutils | | |
| | mogrify command | media-gfx/imagemagick | imagemagick | | |
| | gpg command | app-crypt/gnupg | gnupg | | |
| | python gpg lib | dev-python/python-gnupg | python3-gnupg | | |
| """ | |
| from __future__ import annotations | |
| from __future__ import print_function | |
| import argparse | |
| import glob | |
| import logging | |
| import math | |
| import os | |
| import shutil | |
| import stat | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from typing import Literal | |
| # The following imports will be available on the test image, but will usually | |
| # be missing in the SDK. | |
| # pylint: disable=import-error | |
| import gnupg | |
| class Sensor: | |
| """Hold the parameters for a given fingerprint sensor.""" | |
| def __init__( | |
| self, | |
| name: str, | |
| width: int, | |
| height: int, | |
| bits: int, | |
| frame_size: int, | |
| frame_offset_image: int, | |
| ): | |
| self.name = name | |
| self.width = width | |
| self.height = height | |
| self.bits = bits | |
| # This is the full vendor frame size that encapsulates the captured | |
| # image. | |
| self.frame_size = frame_size | |
| # The odd little offset into the raw vendor frame buffer where the | |
| # capture image begins. | |
| self.frame_offset_image = frame_offset_image | |
| SENSORS = { | |
| "FPC1145": Sensor( | |
| "FPC1145", 56, 192, 8, frame_size=35460, frame_offset_image=2340 | |
| ), | |
| "FPC1025": Sensor( | |
| "FPC1025", 160, 160, 8, frame_size=26260, frame_offset_image=400 | |
| ), | |
| "ELAN80SG": Sensor( | |
| "ELAN80SG", 80, 80, 14, frame_size=12800, frame_offset_image=0 | |
| ), | |
| } | |
| SENSORS_TYPING = Literal["FPC1145", "FPC1025"] | |
| OUTPUT_IMAGE_FILE_EXTS = [ | |
| # The intermediate ASCII Image format. | |
| "pgm", | |
| # The fomllowing are created using a tool. | |
| "pnm", | |
| "png", | |
| "jpg", | |
| ] | |
| OUTPUT_IMAGE_FILE_EXT_TYPING = Literal["pgm", "pnm", "png", "jpg"] | |
| CAPTURE_FILE_EXTS = [ | |
| "gpg", | |
| "raw", | |
| # A special FPC image. | |
| "fmi", | |
| ] | |
| def find_files(path: str, ext: str) -> list[str]: | |
| """Find all files that have the specified file extension. | |
| Args: | |
| path: A directory or single file path, where we will search for file(s) | |
| of the given |ext|. | |
| ext: The file extension. | |
| Returns: | |
| A list of file paths that matching |path| and |ext|. | |
| """ | |
| files = [] | |
| if os.path.isdir(path): | |
| files = glob.glob(path + "/**/*" + ext, recursive=True) | |
| elif os.path.isfile(path): | |
| _, path_ext = os.path.splitext(path) | |
| if path_ext != ext: | |
| raise Exception(f'The given path "{path}" is not a "{ext}" file') | |
| files = [path] | |
| else: | |
| raise Exception(f'The given path "{path}" is not a directory or file') | |
| return files | |
| def decrypt(private_key: str, private_key_pass: str, files: list[str]): | |
| """Decrypt the given file.""" | |
| # Enable basic stdout logging for gnupg. | |
| h = logging.StreamHandler() | |
| l = logging.getLogger("gnupg") | |
| # Change this to logging.DEBUG to debug gnupg issues. | |
| l.setLevel(logging.INFO) | |
| l.addHandler(h) | |
| with tempfile.TemporaryDirectory() as gnupghome: | |
| os.chmod(gnupghome, stat.S_IRWXU) | |
| # Creating this directory makes old gnupg versions happy. | |
| os.makedirs(f"{gnupghome}/private-keys-v1.d", mode=stat.S_IRWXU) | |
| try: | |
| gpg = gnupg.GPG( | |
| gnupghome=gnupghome, | |
| verbose=False, | |
| options=[ | |
| "--no-options", | |
| "--no-default-recipient", | |
| "--trust-model", | |
| "always", | |
| ], | |
| ) | |
| with open(private_key, mode="rb") as key_file: | |
| key_data = key_file.read() | |
| if gpg.import_keys(key_data).count != 1: | |
| raise Exception(f"Failed to import key {private_key}.") | |
| for file in files: | |
| file_parts = os.path.splitext(file) | |
| assert file_parts[1] == ".gpg" | |
| file_output = file_parts[0] | |
| print(f"Decrypting file {file} to {file_output}.") | |
| with open(file, mode="rb") as file_input_stream: | |
| ret = gpg.decrypt_file( | |
| file_input_stream, | |
| always_trust=True, | |
| passphrase=private_key_pass, | |
| output=file_output, | |
| ) | |
| if not ret.ok: | |
| raise Exception(f"Failed to decrypt file {file}") | |
| if not os.path.exists(file_output): | |
| raise Exception( | |
| f"Output file {file_output} was not created" | |
| ) | |
| finally: | |
| # Shred all remnants GPG keys in the temp directory. | |
| os.system(f"find {gnupghome} -type f | xargs shred -v") | |
| def convert( | |
| raw_capture: bytes, | |
| sensor_name: SENSORS_TYPING, | |
| out_type: OUTPUT_IMAGE_FILE_EXT_TYPING, | |
| ) -> bytes: | |
| """Convert a raw fingerprint capture to another usable format.""" | |
| if not sensor_name in SENSORS: | |
| raise ValueError(f"Arg sensor must be one of {SENSORS}.") | |
| if not out_type in OUTPUT_IMAGE_FILE_EXTS: | |
| raise ValueError( | |
| f"Arg out_type must be one of {OUTPUT_IMAGE_FILE_EXTS}." | |
| ) | |
| if out_type != "pgm" and not shutil.which("mogrify"): | |
| # The mogrify utility can be found in the imagemagick package. | |
| raise RuntimeError( | |
| f"Conversion to {out_type} requires the mogrify utility. " | |
| "Please install the imagemagick package." | |
| ) | |
| sensor = SENSORS[sensor_name] | |
| # We always build the ASCII PGM representation of the image. | |
| # If the user wants a PGM image, we just save it to a file. | |
| # If the user wants a more complex type, we feed the PGM representation | |
| # into mogrify and save the output image binary. | |
| # More information about PGM can be found at the following webpages: | |
| # - https://en.wikipedia.org/wiki/Netpbm#File_formats | |
| # - https://netpbm.sourceforge.net/doc/pgm.html | |
| # | |
| # This raw to PGM conversion can also be seen in the upload_pgm_image | |
| # function of ec/common/fpsensor/fpsensor.c and the cmd_fp_frame | |
| # function of ec/util/ectool.c. Check commit description for more info. | |
| pgm_buffer = "" | |
| if len(raw_capture) != sensor.frame_size: | |
| raise ValueError( | |
| f"Raw frame size {len(raw_capture)} != " | |
| f"expected size {sensor.frame_size}." | |
| ) | |
| # Use magic vendor frame offset. | |
| raw_capture = raw_capture[sensor.frame_offset_image :] | |
| # Write graymap PGM ASCII header. | |
| pgm_buffer += "P2\n" | |
| pgm_buffer += f"# {sensor.name} is {sensor.width}x{sensor.height} " | |
| pgm_buffer += f"{sensor.bits}bpp\n" | |
| pgm_buffer += f"{sensor.width} {sensor.height}\n" | |
| # The Max Value can be any value between 0 and 65536, exclusive. | |
| pgm_buffer += "# Max Value:\n" | |
| pixel_max_value = 2**sensor.bits - 1 | |
| pgm_buffer += f"{pixel_max_value}\n" | |
| # Write table of pixel values. | |
| pixel_bytes_count = math.ceil(sensor.bits / 8) | |
| for h in range(sensor.height): | |
| for w in range(sensor.width): | |
| pixel_index = pixel_bytes_count * (sensor.width * h + w) | |
| pixel_raw_bytes = raw_capture[ | |
| pixel_index : pixel_index + pixel_bytes_count | |
| ] | |
| pixel_value = int.from_bytes( | |
| pixel_raw_bytes, | |
| byteorder="little", | |
| signed=False, | |
| ) | |
| if pixel_value > pixel_max_value: | |
| raise ValueError( | |
| f"Parsed pixel value of {pixel_value}" | |
| f"(0x{pixel_raw_bytes.hex()}) at offset {pixel_index} is" | |
| f"larger than max value {pixel_max_value}." | |
| ) | |
| pgm_buffer += f"{pixel_value} " | |
| pgm_buffer += "\n" | |
| # Write non-essential footer. | |
| pgm_buffer += "# END OF FILE\n" | |
| if out_type == "pgm": | |
| return bytes(pgm_buffer, "utf-8") | |
| else: | |
| # The mogrify utility can be found in the imagemagick package. | |
| # mogrify -format png *.pgm | |
| p = subprocess.run( | |
| ["mogrify", "-format", out_type, "-"], | |
| capture_output=True, | |
| input=bytes(pgm_buffer, "utf-8"), | |
| check=False, | |
| ) | |
| if p.returncode != 0: | |
| print("mogrify:", str(p.stderr, "utf-8")) | |
| raise RuntimeError(f"The mogrify utility returned {p.returncode}.") | |
| return bytes(p.stdout) | |
| def cmd_decrypt(args: argparse.Namespace) -> int: | |
| """Decrypt all gpg encrypted fingerprint captures.""" | |
| if not os.path.isfile(args.key): | |
| print(f"Error - The given key file {args.key} does not exist.") | |
| return 1 | |
| try: | |
| files = find_files(args.path, ".gpg") | |
| except Exception as e: | |
| print(f"Error - {e}") | |
| return 1 | |
| if not files: | |
| print("Error - The given path does not contain gpg files.") | |
| return 1 | |
| if not files: | |
| print("Error - The given dir path does not contain encrypted files.") | |
| return 1 | |
| if not shutil.which("shred"): | |
| print("Error - The shred utility does not exist.") | |
| return 1 | |
| try: | |
| decrypt(args.key, args.password, files) | |
| except Exception as e: | |
| print(f"Error - {e}.") | |
| print( | |
| "Ensure that you provided or were prompted for the private " | |
| "key password." | |
| ) | |
| return 1 | |
| return 0 | |
| def cmd_convert(args: argparse.Namespace) -> int: | |
| """Convert all raw samples to the specified output format.""" | |
| try: | |
| files = find_files(args.path, ".raw") | |
| except Exception as e: | |
| print(f"Error - {e}.") | |
| return 1 | |
| if not files: | |
| print("Error - The given path does not contain raw files.") | |
| return 1 | |
| for infile in files: | |
| outfile, _ = os.path.splitext(infile) | |
| outfile += "." + args.outtype | |
| with open(infile, "rb") as fin: | |
| b = fin.read() | |
| out_bytes = convert(b, args.sensor, args.outtype) | |
| with open(outfile, "wb") as fout: | |
| fout.write(out_bytes) | |
| return 0 | |
| def cmd_rm(args: argparse.Namespace) -> int: | |
| """Recursively shred and remove files of a certain extension.""" | |
| try: | |
| files = find_files(args.path, args.ext) | |
| except Exception as e: | |
| print(f"Error - {e}.") | |
| return 1 | |
| if not files: | |
| print(f"Error - The given path does not contain {args.ext} files.") | |
| return 1 | |
| if args.ext in CAPTURE_FILE_EXTS: | |
| print( | |
| f"WARNING: You are about to destroy {len(files)} original " | |
| f'".{args.ext}" fingerprint capture files from path ' | |
| f'"{args.path}".' | |
| ) | |
| resp = input("Confirm y/n: ") | |
| if not resp in ["y", "Y"]: | |
| print("Aborting.") | |
| return 0 | |
| if not shutil.which("shred"): | |
| print("Error - The shred utility does not exist.") | |
| return 1 | |
| files_list = "\n".join(files) + "\n" | |
| print(f"Shredding {len(files)} files.") | |
| p = subprocess.run( | |
| ["xargs", "shred", "-v"], | |
| capture_output=True, | |
| input=bytes(files_list, "utf-8"), | |
| check=False, | |
| ) | |
| if p.returncode != 0: | |
| print("shred stdout:\n", str(p.stdout, "utf-8")) | |
| print("shred stderr:\n", str(p.stderr, "utf-8")) | |
| print(f"Error - shred returned {p.returncode}.") | |
| return 1 | |
| for file in files: | |
| print(f"Removing {file}.") | |
| os.remove(file) | |
| def main(argv: list[str]) -> int: | |
| parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| subparsers = parser.add_subparsers( | |
| dest="subcommand", required=True, title="subcommands" | |
| ) | |
| # Parser for "decrypt" subcommand. | |
| parser_decrypt = subparsers.add_parser("decrypt", help=cmd_decrypt.__doc__) | |
| parser_decrypt.add_argument("key", help="Path to the GPG private key") | |
| parser_decrypt.add_argument( | |
| "path", | |
| help="Path to directory of encrypted captures " | |
| "or single encrypted file", | |
| ) | |
| parser_decrypt.add_argument( | |
| "--password", default=None, help="Password for private key" | |
| ) | |
| parser_decrypt.set_defaults(func=cmd_decrypt) | |
| # Parser for "convert" subcommand. | |
| parser_convert = subparsers.add_parser("convert", help=cmd_convert.__doc__) | |
| parser_convert.add_argument( | |
| "sensor", | |
| choices=SENSORS, | |
| help="The sensor that generated the raw samples", | |
| ) | |
| parser_convert.add_argument( | |
| "outtype", | |
| type=str, | |
| choices=OUTPUT_IMAGE_FILE_EXTS, | |
| help="The output image type to convert to", | |
| ) | |
| parser_convert.add_argument( | |
| "path", help="Path to directory of raw captures or single raw file" | |
| ) | |
| parser_convert.set_defaults(func=cmd_convert) | |
| # Parser for "rm" subcommand. | |
| parser_rm = subparsers.add_parser("rm", help=cmd_rm.__doc__) | |
| parser_rm.add_argument( | |
| "ext", | |
| type=str, | |
| choices=OUTPUT_IMAGE_FILE_EXTS + CAPTURE_FILE_EXTS, | |
| help="The file extension to remove", | |
| ) | |
| parser_rm.add_argument( | |
| "path", help="Path to directory of raw captures or single raw file" | |
| ) | |
| parser_rm.set_defaults(func=cmd_rm) | |
| args = parser.parse_args(argv) | |
| return args.func(args) | |
| if __name__ == "__main__": | |
| sys.exit(main(sys.argv[1:])) |