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 426def CheckDataDuplicity(lines): 427 build_prop = {} 428 for line in lines: 429 if line.startswith("import") or line.startswith("#"): 430 continue 431 key, value = line.split("=", 1) 432 if key in build_prop: 433 return key 434 build_prop[key] = value 435 436def CheckBuildPropDuplicity(input_tmp): 437 """Check all buld.prop files inside directory input_tmp, raise error 438 if they contain duplicates""" 439 440 if not os.path.isdir(input_tmp): 441 raise ValueError("Expect {} to be a directory".format(input_tmp)) 442 for name in os.listdir(input_tmp): 443 if not name.isupper(): 444 continue 445 for prop_file in ['build.prop', 'etc/build.prop']: 446 path = os.path.join(input_tmp, name, prop_file) 447 if not os.path.exists(path): 448 continue 449 logging.info("Checking {}".format(path)) 450 with open(path, 'r') as fp: 451 dupKey = CheckDataDuplicity(fp.readlines()) 452 if dupKey: 453 raise ValueError("{} contains duplicate keys for {}", path, dupKey) 454 455def main(): 456 parser = argparse.ArgumentParser( 457 description=__doc__, 458 formatter_class=argparse.RawDescriptionHelpFormatter) 459 parser.add_argument( 460 'target_files', 461 help='the input target_files.zip to be validated') 462 parser.add_argument( 463 '--verity_key', 464 help='the verity public key to verify the bootable images (Verified ' 465 'Boot 1.0), or the vbmeta image (Verified Boot 2.0, aka AVB), where ' 466 'applicable') 467 for partition in common.AVB_PARTITIONS + common.AVB_VBMETA_PARTITIONS: 468 parser.add_argument( 469 '--avb_' + partition + '_key_path', 470 help='the public or private key in PEM format to verify AVB chained ' 471 'partition of {}'.format(partition)) 472 parser.add_argument( 473 '--verity_key_mincrypt', 474 help='the verity public key in mincrypt format to verify the system ' 475 'images, if target using Verified Boot 1.0') 476 args = parser.parse_args() 477 478 # Unprovided args will have 'None' as the value. 479 options = vars(args) 480 481 logging_format = '%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s' 482 date_format = '%Y/%m/%d %H:%M:%S' 483 logging.basicConfig(level=logging.INFO, format=logging_format, 484 datefmt=date_format) 485 486 logging.info("Unzipping the input target_files.zip: %s", args.target_files) 487 input_tmp = common.UnzipTemp(args.target_files) 488 489 info_dict = common.LoadInfoDict(input_tmp) 490 with zipfile.ZipFile(args.target_files, 'r') as input_zip: 491 ValidateFileConsistency(input_zip, input_tmp, info_dict) 492 493 CheckBuildPropDuplicity(input_tmp) 494 495 ValidateInstallRecoveryScript(input_tmp, info_dict) 496 497 ValidateVerifiedBootImages(input_tmp, info_dict, options) 498 499 # TODO: Check if the OTA keys have been properly updated (the ones on /system, 500 # in recovery image). 501 502 logging.info("Done.") 503 504 505if __name__ == '__main__': 506 try: 507 main() 508 finally: 509 common.Cleanup() 510