blob: 5442c1bcc8647dadc82997ddac78e8f964e5591d [file] [log] [blame] [edit]
#!/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:]))