1#!/usr/bin/env python 2 3# Copyright (C) 2017 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17""" 18Validate a given (signed) target_files.zip. 19 20It performs the following checks to assert the integrity of the input zip. 21 22 - It verifies the file consistency between the ones in IMAGES/system.img (read 23 via IMAGES/system.map) and the ones under unpacked folder of SYSTEM/. The 24 same check also applies to the vendor image if present. 25 26 - It verifies the install-recovery script consistency, by comparing the 27 checksums in the script against the ones of IMAGES/{boot,recovery}.img. 28 29 - It verifies the signed Verified Boot related images, for both of Verified 30 Boot 1.0 and 2.0 (aka AVB). 31""" 32 33import argparse 34import filecmp 35import logging 36import os.path 37import re 38import zipfile 39from hashlib import sha1 40 41import common 42import rangelib 43 44 45def _ReadFile(file_name, unpacked_name, round_up=False): 46 """Constructs and returns a File object. Rounds up its size if needed.""" 47 assert os.path.exists(unpacked_name) 48 with open(unpacked_name, 'rb') as f: 49 file_data = f.read() 50 file_size = len(file_data) 51 if round_up: 52 file_size_rounded_up = common.RoundUpTo4K(file_size) 53 file_data += b'\0' * (file_size_rounded_up - file_size) 54 return common.File(file_name, file_data) 55 56 57def ValidateFileAgainstSha1(input_tmp, file_name, file_path, expected_sha1): 58 """Check if the file has the expected SHA-1.""" 59 60 logging.info('Validating the SHA-1 of %s', file_name) 61 unpacked_name = os.path.join(input_tmp, file_path) 62 assert os.path.exists(unpacked_name) 63 actual_sha1 = _ReadFile(file_name, unpacked_name, False).sha1 64 assert actual_sha1 == expected_sha1, \ 65 'SHA-1 mismatches for {}. actual {}, expected {}'.format( 66 file_name, actual_sha1, expected_sha1) 67 68 69def ValidateFileConsistency(input_zip, input_tmp, info_dict): 70 """Compare the files from image files and unpacked folders.""" 71 72 def CheckAllFiles(which): 73 logging.info('Checking %s image.', which) 74 # Allow having shared blocks when loading the sparse image, because allowing 75 # that doesn't affect the checks below (we will have all the blocks on file, 76 # unless it's skipped due to the holes). 77 image = common.GetSparseImage(which, input_tmp, input_zip, True) 78 prefix = '/' + which 79 for entry in image.file_map: 80 # Skip entries like '__NONZERO-0'. 81 if not entry.startswith(prefix): 82 continue 83 84 # Read the blocks that the file resides. Note that it will contain the 85 # bytes past the file length, which is expected to be padded with '\0's. 86 ranges = image.file_map[entry] 87 88 # Use the original RangeSet if applicable, which includes the shared 89 # blocks. And this needs to happen before checking the monotonicity flag. 90 if ranges.extra.get('uses_shared_blocks'): 91 file_ranges = ranges.extra['uses_shared_blocks'] 92 else: 93 file_ranges = ranges 94 95 incomplete = file_ranges.extra.get('incomplete', False) 96 if incomplete: 97 logging.warning('Skipping %s that has incomplete block list', entry) 98 continue 99 100 # If the file has non-monotonic ranges, read each range in order. 101 if not file_ranges.monotonic: 102 h = sha1() 103 for file_range in file_ranges.extra['text_str'].split(' '): 104 for data in image.ReadRangeSet(rangelib.RangeSet(file_range)): 105 h.update(data) 106 blocks_sha1 = h.hexdigest() 107 else: 108 blocks_sha1 = image.RangeSha1(file_ranges) 109 110 # The filename under unpacked directory, such as SYSTEM/bin/sh. 111 unpacked_name = os.path.join( 112 input_tmp, which.upper(), entry[(len(prefix) + 1):]) 113 unpacked_file = _ReadFile(entry, unpacked_name, True) 114 file_sha1 = unpacked_file.sha1 115 assert blocks_sha1 == file_sha1, \ 116 'file: %s, range: %s, blocks_sha1: %s, file_sha1: %s' % ( 117 entry, file_ranges, blocks_sha1, file_sha1) 118 119 logging.info('Validating file consistency.') 120 121 # TODO(b/79617342): Validate non-sparse images. 122 if info_dict.get('extfs_sparse_flag') != '-s': 123 logging.warning('Skipped due to target using non-sparse images') 124 return 125 126 # Verify IMAGES/system.img. 127 CheckAllFiles('system') 128 129 # Verify IMAGES/vendor.img if applicable. 130 if 'VENDOR/' in input_zip.namelist(): 131 CheckAllFiles('vendor') 132 133 # Not checking IMAGES/system_other.img since it doesn't have the map file. 134 135 136def ValidateInstallRecoveryScript(input_tmp, info_dict): 137 """Validate the SHA-1 embedded in install-recovery.sh. 138 139 install-recovery.sh is written in common.py and has the following format: 140 141 1. full recovery: 142 ... 143 if ! applypatch --check type:device:size:sha1; then 144 applypatch --flash /vendor/etc/recovery.img \\ 145 type:device:size:sha1 && \\ 146 ... 147 148 2. recovery from boot: 149 ... 150 if ! applypatch --check type:recovery_device:recovery_size:recovery_sha1; then 151 applypatch [--bonus bonus_args] \\ 152 --patch /vendor/recovery-from-boot.p \\ 153 --source type:boot_device:boot_size:boot_sha1 \\ 154 --target type:recovery_device:recovery_size:recovery_sha1 && \\ 155 ... 156 157 For full recovery, we want to calculate the SHA-1 of /vendor/etc/recovery.img 158 and compare it against the one embedded in the script. While for recovery 159 from boot, we want to check the SHA-1 for both recovery.img and boot.img 160 under IMAGES/. 161 """ 162 163 board_uses_vendorimage = info_dict.get("board_uses_vendorimage") == "true" 164 165 if board_uses_vendorimage: 166 script_path = 'VENDOR/bin/install-recovery.sh' 167 recovery_img = 'VENDOR/etc/recovery.img' 168 else: 169 script_path = 'SYSTEM/vendor/bin/install-recovery.sh' 170 recovery_img = 'SYSTEM/vendor/etc/recovery.img' 171 172 if not os.path.exists(os.path.join(input_tmp, script_path)): 173 logging.info('%s does not exist in input_tmp', script_path) 174 return 175 176 logging.info('Checking %s', script_path) 177 with open(os.path.join(input_tmp, script_path), 'r') as script: 178 lines = script.read().strip().split('\n') 179 assert len(lines) >= 10 180 check_cmd = re.search(r'if ! applypatch --check (\w+:.+:\w+:\w+);', 181 lines[1].strip()) 182 check_partition = check_cmd.group(1) 183 assert len(check_partition.split(':')) == 4 184 185 full_recovery_image = info_dict.get("full_recovery_image") == "true" 186 if full_recovery_image: 187 assert len(lines) == 10, "Invalid line count: {}".format(lines) 188 189 # Expect something like "EMMC:/dev/block/recovery:28:5f9c..62e3". 190 target = re.search(r'--target (.+) &&', lines[4].strip()) 191 assert target is not None, \ 192 "Failed to parse target line \"{}\"".format(lines[4]) 193 flash_partition = target.group(1) 194 195 # Check we have the same recovery target in the check and flash commands. 196 assert check_partition == flash_partition, \ 197 "Mismatching targets: {} vs {}".format(check_partition, flash_partition) 198 199 # Validate the SHA-1 of the recovery image. 200 recovery_sha1 = flash_partition.split(':')[3] 201 ValidateFileAgainstSha1( 202 input_tmp, 'recovery.img', recovery_img, recovery_sha1) 203 else: 204 assert len(lines) == 11, "Invalid line count: {}".format(lines) 205 206 # --source boot_type:boot_device:boot_size:boot_sha1 207 source = re.search(r'--source (\w+:.+:\w+:\w+) \\', lines[4].strip()) 208 assert source is not None, \ 209 "Failed to parse source line \"{}\"".format(lines[4]) 210 211 source_partition = source.group(1) 212 source_info = source_partition.split(':') 213 assert len(source_info) == 4, \ 214 "Invalid source partition: {}".format(source_partition) 215 ValidateFileAgainstSha1(input_tmp, file_name='boot.img', 216 file_path='IMAGES/boot.img', 217 expected_sha1=source_info[3]) 218 219 # --target recovery_type:recovery_device:recovery_size:recovery_sha1 220 target = re.search(r'--target (\w+:.+:\w+:\w+) && \\', lines[5].strip()) 221 assert target is not None, \ 222 "Failed to parse target line \"{}\"".format(lines[5]) 223 target_partition = target.group(1) 224 225 # Check we have the same recovery target in the check and patch commands. 226 assert check_partition == target_partition, \ 227 "Mismatching targets: {} vs {}".format( 228 check_partition, target_partition) 229 230 recovery_info = target_partition.split(':') 231 assert len(recovery_info) == 4, \ 232 "Invalid target partition: {}".format(target_partition) 233 ValidateFileAgainstSha1(input_tmp, file_name='recovery.img', 234 file_path='IMAGES/recovery.img', 235 expected_sha1=recovery_info[3]) 236 237 logging.info('Done checking %s', script_path) 238 239# Symlink files in `src` to `dst`, if the files do not 240# already exists in `dst` directory. 241def symlinkIfNotExists(src, dst): 242 if not os.path.isdir(src): 243 return 244 for filename in os.listdir(src): 245 if os.path.exists(os.path.join(dst, filename)): 246 continue 247 os.symlink(os.path.join(src, filename), os.path.join(dst, filename)) 248 249def ValidateVerifiedBootImages(input_tmp, info_dict, options): 250 """Validates the Verified Boot related images. 251 252 For Verified Boot 1.0, it verifies the signatures of the bootable images 253 (boot/recovery etc), as well as the dm-verity metadata in system images 254 (system/vendor/product). For Verified Boot 2.0, it calls avbtool to verify 255 vbmeta.img, which in turn verifies all the descriptors listed in vbmeta. 256 257 Args: 258 input_tmp: The top-level directory of unpacked target-files.zip. 259 info_dict: The loaded info dict. 260 options: A dict that contains the user-supplied public keys to be used for 261 image verification. In particular, 'verity_key' is used to verify the 262 bootable images in VB 1.0, and the vbmeta image in VB 2.0, where 263 applicable. 'verity_key_mincrypt' will be used to verify the system 264 images in VB 1.0. 265 266 Raises: 267 AssertionError: On any verification failure. 268 """ 269 # See bug 159299583 270 # After commit 5277d1015, some images (e.g. acpio.img and tos.img) are no 271 # longer copied from RADIO to the IMAGES folder. But avbtool assumes that 272 # images are in IMAGES folder. So we symlink them. 273 symlinkIfNotExists(os.path.join(input_tmp, "RADIO"), 274 os.path.join(input_tmp, "IMAGES")) 275 # Verified boot 1.0 (images signed with boot_signer and verity_signer). 276 if info_dict.get('boot_signer') == 'true': 277 logging.info('Verifying Verified Boot images...') 278 279 # Verify the boot/recovery images (signed with boot_signer), against the 280 # given X.509 encoded pubkey (or falling back to the one in the info_dict if 281 # none given). 282 verity_key = options['verity_key'] 283 if verity_key is None: 284 verity_key = info_dict['verity_key'] + '.x509.pem' 285 for image in ('boot.img', 'recovery.img', 'recovery-two-step.img'): 286 if image == 'recovery-two-step.img': 287 image_path = os.path.join(input_tmp, 'OTA', image) 288 else: 289 image_path = os.path.join(input_tmp, 'IMAGES', image) 290 if not os.path.exists(image_path): 291 continue 292 293 cmd = ['boot_signer', '-verify', image_path, '-certificate', verity_key] 294 proc = common.Run(cmd) 295 stdoutdata, _ = proc.communicate() 296 assert proc.returncode == 0, \ 297 'Failed to verify {} with boot_signer:\n{}'.format(image, stdoutdata) 298 logging.info( 299 'Verified %s with boot_signer (key: %s):\n%s', image, verity_key, 300 stdoutdata.rstrip()) 301 302 # Verify verity signed system images in Verified Boot 1.0. Note that not using 303 # 'elif' here, since 'boot_signer' and 'verity' are not bundled in VB 1.0. 304 if info_dict.get('verity') == 'true': 305 # First verify that the verity key is built into the root image (regardless 306 # of system-as-root). 307 verity_key_mincrypt = os.path.join(input_tmp, 'ROOT', 'verity_key') 308 assert os.path.exists(verity_key_mincrypt), 'Missing verity_key' 309 310 # Verify /verity_key matches the one given via command line, if any. 311 if options['verity_key_mincrypt'] is None: 312 logging.warn( 313 'Skipped checking the content of /verity_key, as the key file not ' 314 'provided. Use --verity_key_mincrypt to specify.') 315 else: 316 expected_key = options['verity_key_mincrypt'] 317 assert filecmp.cmp(expected_key, verity_key_mincrypt, shallow=False), \ 318 "Mismatching mincrypt verity key files" 319 logging.info('Verified the content of /verity_key') 320 321 # For devices with a separate ramdisk (i.e. non-system-as-root), there must 322 # be a copy in ramdisk. 323 if info_dict.get("system_root_image") != "true": 324 verity_key_ramdisk = os.path.join( 325 input_tmp, 'BOOT', 'RAMDISK', 'verity_key') 326 assert os.path.exists(verity_key_ramdisk), 'Missing verity_key in ramdisk' 327 328 assert filecmp.cmp( 329 verity_key_mincrypt, verity_key_ramdisk, shallow=False), \ 330 'Mismatching verity_key files in root and ramdisk' 331 logging.info('Verified the content of /verity_key in ramdisk') 332 333 # Then verify the verity signed system/vendor/product images, against the 334 # verity pubkey in mincrypt format. 335 for image in ('system.img', 'vendor.img', 'product.img'): 336 image_path = os.path.join(input_tmp, 'IMAGES', image) 337 338 # We are not checking if the image is actually enabled via info_dict (e.g. 339 # 'system_verity_block_device=...'). Because it's most likely a bug that 340 # skips signing some of the images in signed target-files.zip, while 341 # having the top-level verity flag enabled. 342 if not os.path.exists(image_path): 343 continue 344 345 cmd = ['verity_verifier', image_path, '-mincrypt', verity_key_mincrypt] 346 proc = common.Run(cmd) 347 stdoutdata, _ = proc.communicate() 348 assert proc.returncode == 0, \ 349 'Failed to verify {} with verity_verifier (key: {}):\n{}'.format( 350 image, verity_key_mincrypt, stdoutdata) 351 logging.info( 352 'Verified %s with verity_verifier (key: %s):\n%s', image, 353 verity_key_mincrypt, stdoutdata.rstrip()) 354 355 # Handle the case of Verified Boot 2.0 (AVB). 356 if info_dict.get("avb_enable") == "true": 357 logging.info('Verifying Verified Boot 2.0 (AVB) images...') 358 359 key = options['verity_key'] 360 if key is None: 361 key = info_dict['avb_vbmeta_key_path'] 362 363 # avbtool verifies all the images that have descriptors listed in vbmeta. 364 # Using `--follow_chain_partitions` so it would additionally verify chained 365 # vbmeta partitions (e.g. vbmeta_system). 366 image = os.path.join(input_tmp, 'IMAGES', 'vbmeta.img') 367 cmd = [info_dict['avb_avbtool'], 'verify_image', '--image', image, 368 '--follow_chain_partitions'] 369 370 # Custom images. 371 custom_partitions = info_dict.get( 372 "avb_custom_images_partition_list", "").strip().split() 373 374 # Append the args for chained partitions if any. 375 for partition in (common.AVB_PARTITIONS + common.AVB_VBMETA_PARTITIONS + 376 tuple(custom_partitions)): 377 key_name = 'avb_' + partition + '_key_path' 378 if info_dict.get(key_name) is not None: 379 if info_dict.get('ab_update') != 'true' and partition == 'recovery': 380 continue 381 382 # Use the key file from command line if specified; otherwise fall back 383 # to the one in info dict. 384 key_file = options.get(key_name, info_dict[key_name]) 385 chained_partition_arg = common.GetAvbChainedPartitionArg( 386 partition, info_dict, key_file) 387 cmd.extend(['--expected_chain_partition', chained_partition_arg]) 388 389 # Handle the boot image with a non-default name, e.g. boot-5.4.img 390 boot_images = info_dict.get("boot_images") 391 if boot_images: 392 # we used the 1st boot image to generate the vbmeta. Rename the filename 393 # to boot.img so that avbtool can find it correctly. 394 first_image_name = boot_images.split()[0] 395 first_image_path = os.path.join(input_tmp, 'IMAGES', first_image_name) 396 assert os.path.isfile(first_image_path) 397 renamed_boot_image_path = os.path.join(input_tmp, 'IMAGES', 'boot.img') 398 os.rename(first_image_path, renamed_boot_image_path) 399 400 proc = common.Run(cmd) 401 stdoutdata, _ = proc.communicate() 402 assert proc.returncode == 0, \ 403 'Failed to verify {} with avbtool (key: {}):\n{}'.format( 404 image, key, stdoutdata) 405 406 logging.info( 407 'Verified %s with avbtool (key: %s):\n%s', image, key, 408 stdoutdata.rstrip()) 409 410 # avbtool verifies recovery image for non-A/B devices. 411 if (info_dict.get('ab_update') != 'true' and 412 info_dict.get('no_recovery') != 'true'): 413 image = os.path.join(input_tmp, 'IMAGES', 'recovery.img') 414 key = info_dict['avb_recovery_key_path'] 415 cmd = [info_dict['avb_avbtool'], 'verify_image', '--image', image, 416 '--key', key] 417 proc = common.Run(cmd) 418 stdoutdata, _ = proc.communicate() 419 assert proc.returncode == 0, \ 420 'Failed to verify {} with avbtool (key: {}):\n{}'.format( 421 image, key, stdoutdata) 422 logging.info( 423 'Verified %s with avbtool (key: %s):\n%s', image, key, 424 stdoutdata.rstrip()) 425 426 427def main(): 428 parser = argparse.ArgumentParser( 429 description=__doc__, 430 formatter_class=argparse.RawDescriptionHelpFormatter) 431 parser.add_argument( 432 'target_files', 433 help='the input target_files.zip to be validated') 434 parser.add_argument( 435 '--verity_key', 436 help='the verity public key to verify the bootable images (Verified ' 437 'Boot 1.0), or the vbmeta image (Verified Boot 2.0, aka AVB), where ' 438 'applicable') 439 for partition in common.AVB_PARTITIONS + common.AVB_VBMETA_PARTITIONS: 440 parser.add_argument( 441 '--avb_' + partition + '_key_path', 442 help='the public or private key in PEM format to verify AVB chained ' 443 'partition of {}'.format(partition)) 444 parser.add_argument( 445 '--verity_key_mincrypt', 446 help='the verity public key in mincrypt format to verify the system ' 447 'images, if target using Verified Boot 1.0') 448 args = parser.parse_args() 449 450 # Unprovided args will have 'None' as the value. 451 options = vars(args) 452 453 logging_format = '%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s' 454 date_format = '%Y/%m/%d %H:%M:%S' 455 logging.basicConfig(level=logging.INFO, format=logging_format, 456 datefmt=date_format) 457 458 logging.info("Unzipping the input target_files.zip: %s", args.target_files) 459 input_tmp = common.UnzipTemp(args.target_files) 460 461 info_dict = common.LoadInfoDict(input_tmp) 462 with zipfile.ZipFile(args.target_files, 'r') as input_zip: 463 ValidateFileConsistency(input_zip, input_tmp, info_dict) 464 465 ValidateInstallRecoveryScript(input_tmp, info_dict) 466 467 ValidateVerifiedBootImages(input_tmp, info_dict, options) 468 469 # TODO: Check if the OTA keys have been properly updated (the ones on /system, 470 # in recovery image). 471 472 logging.info("Done.") 473 474 475if __name__ == '__main__': 476 try: 477 main() 478 finally: 479 common.Cleanup() 480