| # Copyright 2018 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Chrome OS Image file signing.""" |
| |
| import glob |
| import logging |
| import os |
| import re |
| import tempfile |
| |
| from chromite.lib import constants |
| from chromite.lib import cros_build_lib |
| from chromite.lib import image_lib |
| from chromite.lib import kernel_cmdline |
| from chromite.lib import osutils |
| from chromite.signing.lib import firmware |
| from chromite.signing.lib import keys |
| from chromite.utils import key_value_store |
| |
| |
| class Error(Exception): |
| """Base exception for all exceptions in this module.""" |
| |
| |
| class SignImageError(Error): |
| """Error occurred within SignImage.""" |
| |
| |
| def _PathForVbootSigningScripts(path=None): |
| """Get extra_env for finding vboot_reference scripts. |
| |
| Args: |
| path: path to vboot_reference/scripts/image_signing. |
| |
| Returns: |
| Dictionary to pass to run's extra_env so that it finds the scripts. |
| """ |
| if not path: |
| path = os.path.join( |
| constants.SOURCE_ROOT, |
| "src/platform/vboot_reference/scripts/image_signing", |
| ) |
| current_path = os.environ.get("PATH", "").split(":") |
| if path not in current_path: |
| current_path.insert(0, path) |
| return {"PATH": ":".join(current_path)} |
| |
| |
| def GetKernelConfig(loop_kern, check=True): |
| """Get the kernel config for |loop_kern|. |
| |
| Args: |
| loop_kern: Device file for the partition to inspect. |
| check: Whether failure to read the command line is acceptable. |
| |
| Returns: |
| String containing the kernel arguments, or None. |
| """ |
| ret = cros_build_lib.sudo_run( |
| ["dump_kernel_config", loop_kern], |
| print_cmd=False, |
| capture_output=True, |
| check=check, |
| encoding="utf-8", |
| ) |
| if ret.returncode: |
| return None |
| return ret.stdout.strip() |
| |
| |
| def _GetKernelCmdLine(loop_kern, check=True) -> None: |
| """Get the kernel commandline for |loop_kern|. |
| |
| Args: |
| loop_kern: Device file for the partition to inspect. |
| check: Whether failure to read the command line is acceptable. |
| |
| Returns: |
| CommandLine() containing the kernel config. |
| """ |
| config = GetKernelConfig(loop_kern, check) |
| if config is None: |
| return None |
| else: |
| return kernel_cmdline.CommandLine(config) |
| |
| |
| def SignImage( |
| image_type, |
| input_file, |
| output_file, |
| kernel_part_id, |
| keydir, |
| keyA_prefix="", |
| vboot_path=None, |
| ) -> None: |
| """Sign the image file. |
| |
| A Chromium OS image file (INPUT) always contains 2 partitions (kernel A & |
| B). This function will rebuild hash data by DM_PARTNO, resign kernel |
| partitions by their KEYBLOCK and PRIVKEY files, and then write to OUTPUT |
| file. Note some special images (specified by IMAGE_TYPE, like 'recovery' or |
| 'factory_install') may have additional steps (ex, tweaking verity hash or |
| not stripping files) when generating output file. |
| |
| Args: |
| image_type: Type of image (e.g., 'factory', 'recovery'). |
| input_file: Image to sign. (read-only: copied to output_file). |
| output_file: Signed image. (file is created here) |
| kernel_part_id: partition number (or name) for the kernel (usually 2, |
| 4 on recovery media.) |
| keydir: Path of keyset dir to use. |
| keyA_prefix: Prefix for kernA key (e.g., 'recovery_'). |
| vboot_path: Vboot_reference/scripts/image_signing dir path. |
| |
| Raises: |
| SignImageException |
| """ |
| extra_env = _PathForVbootSigningScripts(vboot_path) |
| logging.info("Preparing %s image...", image_type) |
| cros_build_lib.run(["cp", "--sparse=always", input_file, output_file]) |
| |
| keyset = keys.Keyset(keydir) |
| |
| firmware.ResignImageFirmware(output_file, keyset) |
| |
| with osutils.TempDir() as dest_dir: |
| with image_lib.LoopbackPartitions(output_file, dest_dir) as image: |
| rootfs_dir = image.Mount(("ROOT-A",))[0] |
| SignAndroidImage(rootfs_dir, keyset, vboot_path=vboot_path) |
| SignUefiBinaries(image, rootfs_dir, keyset, vboot_path=vboot_path) |
| image.Unmount(("ROOT-A",)) |
| |
| # TODO(lamontjones): From this point on, all we really want at the |
| # moment is the loopback devicefile names, but that may change as |
| # we implement more of the shell functions. |
| # |
| # We do not actually want to have any filesystems mounted at this |
| # point. |
| |
| loop_kernA = image.GetPartitionDevName("KERN-A") |
| loop_rootfs = image.GetPartitionDevName("ROOT-A") |
| loop_kern = image.GetPartitionDevName(kernel_part_id) |
| kernA_cmd = _GetKernelCmdLine(loop_kernA) |
| if ( |
| image_type != "factory_install" |
| and not kernA_cmd.GetKernelParameter("cros_legacy") |
| and not kernA_cmd.GetKernelParameter("cros_efi") |
| ): |
| cros_build_lib.run( |
| ["strip_boot_from_image.sh", "--image", loop_rootfs], |
| extra_env=extra_env, |
| ) |
| ClearResignFlag(image) |
| UpdateRootfsHash(image, loop_kern, keyset, keyA_prefix) |
| UpdateStatefulPartitionVblock(image, keyset) |
| if image_type == "recovery": |
| UpdateRecoveryKernelHash(image, keyset) |
| UpdateLegacyBootloader(image, loop_kern) |
| |
| logging.info("Signed %s image written to %s", image_type, output_file) |
| |
| |
| def SignAndroidImage(rootfs_dir, keyset, vboot_path=None) -> None: |
| """If there is an android image, sign it.""" |
| system_img = os.path.join( |
| rootfs_dir, "opt/google/containers/android/system.raw.img" |
| ) |
| if not os.path.exists(system_img): |
| logging.info("ARC image not found. Not signing Android APKs.") |
| return |
| |
| arc_version = key_value_store.LoadFile( |
| os.path.join(rootfs_dir, "etc/lsb-release") |
| ).get("CHROMEOS_ARC_VERSION", "") |
| if not arc_version: |
| logging.warning( |
| "CHROMEOS_ARC_VERSION not found in lsb-release. " |
| "Not signing Android APKs." |
| ) |
| return |
| |
| extra_env = _PathForVbootSigningScripts(vboot_path) |
| logging.info("Found ARC image version %s, resigning APKs", arc_version) |
| # Sign the Android APKs using ${keyset.key_dir}/android keys. |
| android_keydir = os.path.join(keyset.key_dir, "android") |
| logging.info("Using %s", android_keydir) |
| |
| # TODO(lamontjones) migrate sign_android_image.sh. |
| cros_build_lib.run( |
| ["sign_android_image.sh", rootfs_dir, android_keydir], |
| extra_env=extra_env, |
| ) |
| |
| |
| def SignUefiBinaries(image, rootfs_dir, keyset, vboot_path=None) -> None: |
| """Sign UEFI binaries if appropriate.""" |
| # If there are no uefi keys in the keyset, we're done. |
| uefi_keydir = os.path.join(keyset.key_dir, "uefi") |
| if not os.path.isdir(uefi_keydir): |
| logging.info("No UEFI keys in keyset. Skipping.") |
| return |
| |
| # Mount the UEFI partition and sign the contents. |
| try: |
| uefi_fsdir = image.Mount(("EFI-SYSTEM",))[0] |
| except KeyError: |
| # Image has no EFI-SYSTEM partition. |
| logging.info("No EFI-SYSTEM partition found.") |
| return |
| |
| extra_env = _PathForVbootSigningScripts(vboot_path) |
| # Sign the UEFI binaries on the EFI partition using |
| # ${keyset.key_dir}/uefi keys. |
| # TODO(lamontjones): convert install_gsetup_certs.sh to python. |
| cros_build_lib.run( |
| ["install_gsetup_certs.sh", uefi_fsdir, uefi_keydir], |
| extra_env=extra_env, |
| ) |
| # TODO(lamontjones): convert sign_uefi.sh to python. |
| cros_build_lib.run( |
| ["sign_uefi.sh", uefi_fsdir, uefi_keydir], extra_env=extra_env |
| ) |
| |
| # TODO(lamontjones): convert sign_uefi.sh to python. |
| cros_build_lib.run( |
| ["sign_uefi.sh", os.path.join(rootfs_dir, "boot"), uefi_keydir], |
| extra_env=extra_env, |
| ) |
| |
| logging.info("Signed UEFI binaries.") |
| |
| |
| class CalculateRootfsHash: |
| """Hash info, and other facts about it, suitable for comparison or copying. |
| |
| Instantiating this class causes it to calculate a new DmConfig and |
| CommandLine for the given image, as well as creating a file with the |
| new hashtree for the image. The temporary file is deleted along with the |
| instance. |
| |
| Examples: |
| (See UpdateRootfsHash below) |
| image = image_lib.LoopbackPartitions(image_path) |
| rootfs_hash = CalculateRootfsHash( |
| image, kernel_cmdline.CommandLine( |
| image.GetPartitionDevName('KERN-A') |
| ) |
| ) |
| <copy or compare updated hashtree, dm_config, |
| kernel_cmdline to the image> |
| <do other things, confident that when rootfs_hash is garbage collected, |
| the underlying new hashtree file will be deleted.> |
| |
| Attributes: |
| calculated_dm_config: Updated DmConfig for the kernel |
| calculated_kernel_cmdline: New kernel_cmdline.CommandLine |
| hashtree_filename: Name of the temporary file containing the new |
| hashtree. |
| """ |
| |
| def __init__(self, image, cmd_line) -> None: |
| """Create the hash_image for the rootfs. |
| |
| Args: |
| image: image_lib.LoopbackPartitions() for the image. |
| cmd_line: kernel_cmdline.CommandLine for the kernel. |
| """ |
| self.image = image |
| self.cmd_line = cmd_line |
| loop_rootfs = image.GetPartitionDevName("ROOT-A") |
| |
| # pylint: disable=consider-using-with |
| self._file = tempfile.NamedTemporaryFile( |
| dir=image.destination, delete=False |
| ) |
| # pylint: enable=consider-using-with |
| |
| dm_config = cmd_line.GetDmConfig() |
| if not dm_config: |
| logging.warning( |
| "Couldn't grab dm_config. Aborting rootfs hash calculation." |
| ) |
| # TODO(lamontjones): This should probably raise an exception. |
| return |
| vroot_dev = dm_config.devices["vroot"] |
| # Get the verity args from the existing DmConfig. |
| rootfs_blocks = int(vroot_dev.GetVerityArg("hashstart").value) // 8 |
| alg = vroot_dev.GetVerityArg("alg").value |
| root_dev = vroot_dev.GetVerityArg("payload").value |
| hash_dev = vroot_dev.GetVerityArg("hashtree").value |
| salt = vroot_dev.GetVerityArg("salt") |
| |
| cmd = [ |
| "verity", |
| "--mode=create", |
| "--alg=%s" % alg, |
| "--payload=%s" % loop_rootfs, |
| "--payload_blocks=%d" % rootfs_blocks, |
| "--hashtree=%s" % self._file.name, |
| ] |
| if salt: |
| cmd.append("--salt=%s" % salt.value) |
| target_template = cros_build_lib.sudo_run( |
| cmd, print_cmd=False, capture_output=True, encoding="utf-8" |
| ).stdout |
| # target_template is a templated DmLine string. |
| target = kernel_cmdline.DmLine( |
| target_template.replace("ROOT_DEV", root_dev).replace( |
| "HASH_DEV", hash_dev |
| ) |
| ) |
| vroot_dev.rows[0] = target |
| self.calculated_dm_config = dm_config |
| self.calculated_kernel_cmdline = self.cmd_line |
| self.hashtree_filename = self._file.name |
| |
| def __del__(self) -> None: |
| if getattr(self, "_file", None): |
| os.unlink(self._file.name) |
| del self._file |
| |
| |
| def ClearResignFlag(image) -> None: |
| """Remove any /root/.need_to_be_signed file from the rootfs. |
| |
| Args: |
| image: image_lib.LoopbackPartitions instance for this image. |
| """ |
| # Check and clear the need_to_resign tag file. |
| rootfs_dir = image.Mount(("ROOT-A",), mount_opts=("rw",))[0] |
| needs_to_be_signed = os.path.join(rootfs_dir, "root/.need_to_be_signed") |
| if os.path.exists(needs_to_be_signed): |
| image.Mount(("ROOT-A",), mount_opts=("remount", "rw")) |
| osutils.SafeUnlink(needs_to_be_signed, sudo=True) |
| image.Unmount(("ROOT-A",)) |
| |
| |
| def UpdateRootfsHash(image, loop_kern, keyset, keyA_prefix) -> None: |
| """Update the root filesystem hash. |
| |
| Args: |
| image: image_lib.LoopbackPartitions instance for this image. |
| loop_kern: Device file name for the kernel partition to hash. |
| keyset: Kernel_cmdline.Keyset to use. |
| keyA_prefix: Prefix for kernA key (e.g., 'recovery_'). |
| """ |
| logging.info( |
| "Updating rootfs hash and updating cmdline for kernel partitions" |
| ) |
| logging.info( |
| "%s (in %s) keyset=%s keyA_prefix=%s", |
| loop_kern, |
| image.path, |
| keyset.key_dir, |
| keyA_prefix, |
| ) |
| cmd_line = _GetKernelCmdLine(loop_kern, check=False) |
| if cmd_line: |
| dm_config = cmd_line.GetDmConfig() |
| if not cmd_line or not dm_config: |
| logging.error("Couldn't get dm_config from kernel %s", loop_kern) |
| logging.error(" (cmdline: %s)", cmd_line) |
| raise SignImageError("Could not get dm_config from kernel") |
| |
| loop_rootfs = image.GetPartitionDevName("ROOT-A") |
| image.DisableRwMount("ROOT-A") |
| rootfs_hash = CalculateRootfsHash(image, cmd_line) |
| fsinfo = cros_build_lib.sudo_run( |
| ["tune2fs", "-l", image.GetPartitionDevName("ROOT-A")], |
| capture_output=True, |
| encoding="utf-8", |
| ).stdout |
| rootfs_blocks = int( |
| re.search( |
| r"^Block count: *([0-9]+)$", fsinfo, flags=re.MULTILINE |
| ).group(1) |
| ) |
| rootfs_sectors = 8 * rootfs_blocks |
| |
| # Overwrite the appended hashes in the rootfs. |
| cros_build_lib.sudo_run( |
| [ |
| "dd", |
| "if=%s" % rootfs_hash.hashtree_filename, |
| "of=%s" % loop_rootfs, |
| "bs=512", |
| "seek=%d" % rootfs_sectors, |
| "conv=notrunc", |
| ], |
| stderr=True, |
| ) |
| |
| # Update kernel command lines. |
| for kern in ("KERN-A", "KERN-B"): |
| loop_kern = image.GetPartitionDevName(kern) |
| new_cmd_line = _GetKernelCmdLine(loop_kern, check=False) |
| if not new_cmd_line and kern == "KERN-B": |
| logging.info("Skipping empty KERN-B partition (legacy images).") |
| continue |
| new_cmd_line.SetDmConfig(rootfs_hash.calculated_dm_config) |
| logging.info("New cmdline for %s partition is: %s", kern, new_cmd_line) |
| if kern == "KERN-A": |
| key = keyset.keys["%skernel_data_key" % keyA_prefix] |
| else: |
| key = keyset.keys["kernel_data_key"] |
| _UpdateKernelConfig(loop_kern, new_cmd_line, key) |
| |
| |
| def _UpdateKernelConfig(loop_kern, cmdline, key) -> None: |
| """Update the kernel config for |loop_kern|. |
| |
| Args: |
| loop_kern: Device file for the partition to inspect. |
| cmdline: CommandLine instance to set. |
| key: Key to use. |
| """ |
| with tempfile.NamedTemporaryFile() as temp: |
| temp.file.write(cmdline.Format().encode("utf-8")) |
| temp.file.flush() |
| |
| cros_build_lib.sudo_run( |
| [ |
| "vbutil_kernel", |
| "--repack", |
| loop_kern, |
| "--keyblock", |
| key.keyblock, |
| "--signprivate", |
| key.private, |
| "--version", |
| str(key.version), |
| "--oldblob", |
| loop_kern, |
| "--config", |
| temp.name, |
| ] |
| ) |
| |
| |
| def UpdateStatefulPartitionVblock(image, keyset) -> None: |
| """Update the SSD install-able vblock file on stateful partition. |
| |
| This is deprecated because all new images should have a SSD boot-able kernel |
| in partition 4. However, the signer needs to be able to sign new & old |
| images (crbug.com/449450#c13) so we will probably never remove this. |
| |
| Args: |
| image: image_lib.LoopbackPartitions() for the image. |
| keyset: Keyset to use for signing |
| """ |
| with tempfile.NamedTemporaryFile(dir=image.destination) as tmpfile: |
| loop_kern = image.GetPartitionDevName("KERN-B") |
| ret = _GetKernelCmdLine(loop_kern, check=False) |
| if not ret: |
| logging.info( |
| "Building vmlinuz_hd.vblock from legacy image partition 2." |
| ) |
| loop_kern = image.GetPartitionDevName(2) |
| |
| kernel_key = keyset.keys["kernel_data_key"] |
| cros_build_lib.sudo_run( |
| [ |
| "vbutil_kernel", |
| "--repack", |
| tmpfile.name, |
| "--keyblock", |
| kernel_key.keyblock, |
| "--signprivate", |
| kernel_key.private, |
| "--oldblob", |
| loop_kern, |
| "--vblockonly", |
| ] |
| ) |
| state_dir = image.Mount(("STATE",), mount_opts=("rw",))[0] |
| cros_build_lib.sudo_run( |
| ["cp", tmpfile.name, os.path.join(state_dir, "vmlinuz_hd.vblock")] |
| ) |
| image.Unmount(("STATE",)) |
| |
| |
| def UpdateRecoveryKernelHash(image, keyset) -> None: |
| """Update the recovery kernel hash.""" |
| loop_kernA = image.GetPartitionDevName("KERN-A") |
| loop_kernB = image.GetPartitionDevName("KERN-B") |
| |
| # Update the KERN-B hash in the KERN-A command line. |
| kernA_cmd = _GetKernelCmdLine(loop_kernA) |
| old_kernB_hash = kernA_cmd.GetKernelParameter("kern_b_hash") |
| |
| if old_kernB_hash: |
| cmd = "sha256sum" if len(old_kernB_hash.value) >= 64 else "sha1sum" |
| new_kernB_hash = cros_build_lib.sudo_run( |
| [cmd, loop_kernB], stdout=True, encoding="utf-8" |
| ).stdout.split()[0] |
| kernA_cmd.SetKernelParameter("kern_b_hash", new_kernB_hash) |
| logging.info("New cmdline for kernel A is %s", str(kernA_cmd)) |
| recovery_key = keyset.keys["recovery"] |
| _UpdateKernelConfig(loop_kernA, kernA_cmd, recovery_key) |
| |
| |
| def UpdateLegacyBootloader(image, loop_kern) -> None: |
| """Update the legacy bootloader templates in EFI partition.""" |
| try: |
| uefi_dir = image.Mount(("EFI-SYSTEM",))[0] |
| except KeyError: |
| # Image has no EFI-SYSTEM partition. |
| logging.info( |
| "Could not mount EFI partition for updating legacy bootloader cfg." |
| ) |
| raise SignImageError("Could not mount EFI partition") |
| |
| root_digest = "" |
| cmd_line = _GetKernelCmdLine(loop_kern) |
| if cmd_line: |
| dm_config = cmd_line.GetDmConfig() |
| if dm_config: |
| vroot_dev = dm_config.devices["vroot"] |
| if vroot_dev: |
| root_digest = vroot_dev.GetVerityArg("root_hexdigest") |
| if root_digest: |
| root_digest = root_digest.value |
| if not root_digest: |
| logging.error( |
| "Could not grab root_digest from kernel partition %s", loop_kern |
| ) |
| logging.error("cmdline: %s", cmd_line) |
| raise SignImageError("Could not find root digest") |
| |
| files = [] |
| sys_dir = os.path.join(uefi_dir, "syslinux") |
| if os.path.isdir(sys_dir): |
| files += glob.glob(os.path.join(sys_dir, "*.cfg")) |
| grub_cfg = os.path.join(uefi_dir, "efi/boot/grub.cfg") |
| if os.path.exists(grub_cfg): |
| files.append(grub_cfg) |
| if files: |
| ret = cros_build_lib.sudo_run( |
| [ |
| "sed", |
| "-iE", |
| r"s/\broot_hexdigest=[a-z0-9]+/root_hexdigest=%s/g" |
| % root_digest, |
| ] |
| + files, |
| check=False, |
| ) |
| if ret.returncode: |
| logging.error( |
| "Updating bootloader configs failed: %s", " ".join(files) |
| ) |
| raise SignImageError("Updating bootloader configs failed") |
| |
| |
| def DumpConfig(image_file) -> None: |
| """Dump kernel config for both kernels. |
| |
| This implements the necessary logic for bin/dump_config, which is intended |
| primarily for debugging of images. |
| |
| Args: |
| image_file: path to the image file from which to dump kernel configs. |
| """ |
| with image_lib.LoopbackPartitions(image_file) as image: |
| for kernel_part in ("KERN-A", "KERN-B"): |
| loop_kern = image.GetPartitionDevName(kernel_part) |
| config = GetKernelConfig(loop_kern, check=False) |
| if config: |
| logging.info("Partition %s", kernel_part) |
| logging.info(config) |
| else: |
| logging.info("Partition %s has no configuration.", kernel_part) |