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 39 40import common 41 42 43def _ReadFile(file_name, unpacked_name, round_up=False): 44 """Constructs and returns a File object. Rounds up its size if needed.""" 45 46 assert os.path.exists(unpacked_name) 47 with open(unpacked_name, 'r') as f: 48 file_data = f.read() 49 file_size = len(file_data) 50 if round_up: 51 file_size_rounded_up = common.RoundUpTo4K(file_size) 52 file_data += '\0' * (file_size_rounded_up - file_size) 53 return common.File(file_name, file_data) 54 55 56def ValidateFileAgainstSha1(input_tmp, file_name, file_path, expected_sha1): 57 """Check if the file has the expected SHA-1.""" 58 59 logging.info('Validating the SHA-1 of %s', file_name) 60 unpacked_name = os.path.join(input_tmp, file_path) 61 assert os.path.exists(unpacked_name) 62 actual_sha1 = _ReadFile(file_name, unpacked_name, False).sha1 63 assert actual_sha1 == expected_sha1, \ 64 'SHA-1 mismatches for {}. actual {}, expected {}'.format( 65 file_name, actual_sha1, expected_sha1) 66 67 68def ValidateFileConsistency(input_zip, input_tmp, info_dict): 69 """Compare the files from image files and unpacked folders.""" 70 71 def CheckAllFiles(which): 72 logging.info('Checking %s image.', which) 73 # Allow having shared blocks when loading the sparse image, because allowing 74 # that doesn't affect the checks below (we will have all the blocks on file, 75 # unless it's skipped due to the holes). 76 image = common.GetSparseImage(which, input_tmp, input_zip, True) 77 prefix = '/' + which 78 for entry in image.file_map: 79 # Skip entries like '__NONZERO-0'. 80 if not entry.startswith(prefix): 81 continue 82 83 # Read the blocks that the file resides. Note that it will contain the 84 # bytes past the file length, which is expected to be padded with '\0's. 85 ranges = image.file_map[entry] 86 87 # Use the original RangeSet if applicable, which includes the shared 88 # blocks. And this needs to happen before checking the monotonicity flag. 89 if ranges.extra.get('uses_shared_blocks'): 90 file_ranges = ranges.extra['uses_shared_blocks'] 91 else: 92 file_ranges = ranges 93 94 incomplete = file_ranges.extra.get('incomplete', False) 95 if incomplete: 96 logging.warning('Skipping %s that has incomplete block list', entry) 97 continue 98 99 # TODO(b/79951650): Handle files with non-monotonic ranges. 100 if not file_ranges.monotonic: 101 logging.warning( 102 'Skipping %s that has non-monotonic ranges: %s', entry, file_ranges) 103 continue 104 105 blocks_sha1 = image.RangeSha1(file_ranges) 106 107 # The filename under unpacked directory, such as SYSTEM/bin/sh. 108 unpacked_name = os.path.join( 109 input_tmp, which.upper(), entry[(len(prefix) + 1):]) 110 unpacked_file = _ReadFile(entry, unpacked_name, True) 111 file_sha1 = unpacked_file.sha1 112 assert blocks_sha1 == file_sha1, \ 113 'file: %s, range: %s, blocks_sha1: %s, file_sha1: %s' % ( 114 entry, file_ranges, blocks_sha1, file_sha1) 115 116 logging.info('Validating file consistency.') 117 118 # TODO(b/79617342): Validate non-sparse images. 119 if info_dict.get('extfs_sparse_flag') != '-s': 120 logging.warning('Skipped due to target using non-sparse images') 121 return 122 123 # Verify IMAGES/system.img. 124 CheckAllFiles('system') 125 126 # Verify IMAGES/vendor.img if applicable. 127 if 'VENDOR/' in input_zip.namelist(): 128 CheckAllFiles('vendor') 129 130 # Not checking IMAGES/system_other.img since it doesn't have the map file. 131 132 133def ValidateInstallRecoveryScript(input_tmp, info_dict): 134 """Validate the SHA-1 embedded in install-recovery.sh. 135 136 install-recovery.sh is written in common.py and has the following format: 137 138 1. full recovery: 139 ... 140 if ! applypatch --check type:device:size:sha1; then 141 applypatch --flash /system/etc/recovery.img \\ 142 type:device:size:sha1 && \\ 143 ... 144 145 2. recovery from boot: 146 ... 147 if ! applypatch --check type:recovery_device:recovery_size:recovery_sha1; then 148 applypatch [--bonus bonus_args] \\ 149 --patch /system/recovery-from-boot.p \\ 150 --source type:boot_device:boot_size:boot_sha1 \\ 151 --target type:recovery_device:recovery_size:recovery_sha1 && \\ 152 ... 153 154 For full recovery, we want to calculate the SHA-1 of /system/etc/recovery.img 155 and compare it against the one embedded in the script. While for recovery 156 from boot, we want to check the SHA-1 for both recovery.img and boot.img 157 under IMAGES/. 158 """ 159 160 script_path = 'SYSTEM/bin/install-recovery.sh' 161 if not os.path.exists(os.path.join(input_tmp, script_path)): 162 logging.info('%s does not exist in input_tmp', script_path) 163 return 164 165 logging.info('Checking %s', script_path) 166 with open(os.path.join(input_tmp, script_path), 'r') as script: 167 lines = script.read().strip().split('\n') 168 assert len(lines) >= 10 169 check_cmd = re.search(r'if ! applypatch --check (\w+:.+:\w+:\w+);', 170 lines[1].strip()) 171 check_partition = check_cmd.group(1) 172 assert len(check_partition.split(':')) == 4 173 174 full_recovery_image = info_dict.get("full_recovery_image") == "true" 175 if full_recovery_image: 176 assert len(lines) == 10, "Invalid line count: {}".format(lines) 177 178 # Expect something like "EMMC:/dev/block/recovery:28:5f9c..62e3". 179 target = re.search(r'--target (.+) &&', lines[4].strip()) 180 assert target is not None, \ 181 "Failed to parse target line \"{}\"".format(lines[4]) 182 flash_partition = target.group(1) 183 184 # Check we have the same recovery target in the check and flash commands. 185 assert check_partition == flash_partition, \ 186 "Mismatching targets: {} vs {}".format(check_partition, flash_partition) 187 188 # Validate the SHA-1 of the recovery image. 189 recovery_sha1 = flash_partition.split(':')[3] 190 ValidateFileAgainstSha1( 191 input_tmp, 'recovery.img', 'SYSTEM/etc/recovery.img', recovery_sha1) 192 else: 193 assert len(lines) == 11, "Invalid line count: {}".format(lines) 194 195 # --source boot_type:boot_device:boot_size:boot_sha1 196 source = re.search(r'--source (\w+:.+:\w+:\w+) \\', lines[4].strip()) 197 assert source is not None, \ 198 "Failed to parse source line \"{}\"".format(lines[4]) 199 200 source_partition = source.group(1) 201 source_info = source_partition.split(':') 202 assert len(source_info) == 4, \ 203 "Invalid source partition: {}".format(source_partition) 204 ValidateFileAgainstSha1(input_tmp, file_name='boot.img', 205 file_path='IMAGES/boot.img', 206 expected_sha1=source_info[3]) 207 208 # --target recovery_type:recovery_device:recovery_size:recovery_sha1 209 target = re.search(r'--target (\w+:.+:\w+:\w+) && \\', lines[5].strip()) 210 assert target is not None, \ 211 "Failed to parse target line \"{}\"".format(lines[5]) 212 target_partition = target.group(1) 213 214 # Check we have the same recovery target in the check and patch commands. 215 assert check_partition == target_partition, \ 216 "Mismatching targets: {} vs {}".format( 217 check_partition, target_partition) 218 219 recovery_info = target_partition.split(':') 220 assert len(recovery_info) == 4, \ 221 "Invalid target partition: {}".format(target_partition) 222 ValidateFileAgainstSha1(input_tmp, file_name='recovery.img', 223 file_path='IMAGES/recovery.img', 224 expected_sha1=recovery_info[3]) 225 226 logging.info('Done checking %s', script_path) 227 228 229def ValidateVerifiedBootImages(input_tmp, info_dict, options): 230 """Validates the Verified Boot related images. 231 232 For Verified Boot 1.0, it verifies the signatures of the bootable images 233 (boot/recovery etc), as well as the dm-verity metadata in system images 234 (system/vendor/product). For Verified Boot 2.0, it calls avbtool to verify 235 vbmeta.img, which in turn verifies all the descriptors listed in vbmeta. 236 237 Args: 238 input_tmp: The top-level directory of unpacked target-files.zip. 239 info_dict: The loaded info dict. 240 options: A dict that contains the user-supplied public keys to be used for 241 image verification. In particular, 'verity_key' is used to verify the 242 bootable images in VB 1.0, and the vbmeta image in VB 2.0, where 243 applicable. 'verity_key_mincrypt' will be used to verify the system 244 images in VB 1.0. 245 246 Raises: 247 AssertionError: On any verification failure. 248 """ 249 # Verified boot 1.0 (images signed with boot_signer and verity_signer). 250 if info_dict.get('boot_signer') == 'true': 251 logging.info('Verifying Verified Boot images...') 252 253 # Verify the boot/recovery images (signed with boot_signer), against the 254 # given X.509 encoded pubkey (or falling back to the one in the info_dict if 255 # none given). 256 verity_key = options['verity_key'] 257 if verity_key is None: 258 verity_key = info_dict['verity_key'] + '.x509.pem' 259 for image in ('boot.img', 'recovery.img', 'recovery-two-step.img'): 260 image_path = os.path.join(input_tmp, 'IMAGES', image) 261 if not os.path.exists(image_path): 262 continue 263 264 cmd = ['boot_signer', '-verify', image_path, '-certificate', verity_key] 265 proc = common.Run(cmd) 266 stdoutdata, _ = proc.communicate() 267 assert proc.returncode == 0, \ 268 'Failed to verify {} with boot_signer:\n{}'.format(image, stdoutdata) 269 logging.info( 270 'Verified %s with boot_signer (key: %s):\n%s', image, verity_key, 271 stdoutdata.rstrip()) 272 273 # Verify verity signed system images in Verified Boot 1.0. Note that not using 274 # 'elif' here, since 'boot_signer' and 'verity' are not bundled in VB 1.0. 275 if info_dict.get('verity') == 'true': 276 # First verify that the verity key that's built into the root image (as 277 # /verity_key) matches the one given via command line, if any. 278 if info_dict.get("system_root_image") == "true": 279 verity_key_mincrypt = os.path.join(input_tmp, 'ROOT', 'verity_key') 280 else: 281 verity_key_mincrypt = os.path.join( 282 input_tmp, 'BOOT', 'RAMDISK', 'verity_key') 283 assert os.path.exists(verity_key_mincrypt), 'Missing verity_key' 284 285 if options['verity_key_mincrypt'] is None: 286 logging.warn( 287 'Skipped checking the content of /verity_key, as the key file not ' 288 'provided. Use --verity_key_mincrypt to specify.') 289 else: 290 expected_key = options['verity_key_mincrypt'] 291 assert filecmp.cmp(expected_key, verity_key_mincrypt, shallow=False), \ 292 "Mismatching mincrypt verity key files" 293 logging.info('Verified the content of /verity_key') 294 295 # Then verify the verity signed system/vendor/product images, against the 296 # verity pubkey in mincrypt format. 297 for image in ('system.img', 'vendor.img', 'product.img'): 298 image_path = os.path.join(input_tmp, 'IMAGES', image) 299 300 # We are not checking if the image is actually enabled via info_dict (e.g. 301 # 'system_verity_block_device=...'). Because it's most likely a bug that 302 # skips signing some of the images in signed target-files.zip, while 303 # having the top-level verity flag enabled. 304 if not os.path.exists(image_path): 305 continue 306 307 cmd = ['verity_verifier', image_path, '-mincrypt', verity_key_mincrypt] 308 proc = common.Run(cmd) 309 stdoutdata, _ = proc.communicate() 310 assert proc.returncode == 0, \ 311 'Failed to verify {} with verity_verifier (key: {}):\n{}'.format( 312 image, verity_key_mincrypt, stdoutdata) 313 logging.info( 314 'Verified %s with verity_verifier (key: %s):\n%s', image, 315 verity_key_mincrypt, stdoutdata.rstrip()) 316 317 # Handle the case of Verified Boot 2.0 (AVB). 318 if info_dict.get("avb_enable") == "true": 319 logging.info('Verifying Verified Boot 2.0 (AVB) images...') 320 321 key = options['verity_key'] 322 if key is None: 323 key = info_dict['avb_vbmeta_key_path'] 324 325 # avbtool verifies all the images that have descriptors listed in vbmeta. 326 image = os.path.join(input_tmp, 'IMAGES', 'vbmeta.img') 327 cmd = ['avbtool', 'verify_image', '--image', image, '--key', key] 328 329 # Append the args for chained partitions if any. 330 for partition in common.AVB_PARTITIONS: 331 key_name = 'avb_' + partition + '_key_path' 332 if info_dict.get(key_name) is not None: 333 chained_partition_arg = common.GetAvbChainedPartitionArg( 334 partition, info_dict, options[key_name]) 335 cmd.extend(["--expected_chain_partition", chained_partition_arg]) 336 337 proc = common.Run(cmd) 338 stdoutdata, _ = proc.communicate() 339 assert proc.returncode == 0, \ 340 'Failed to verify {} with avbtool (key: {}):\n{}'.format( 341 image, key, stdoutdata) 342 343 logging.info( 344 'Verified %s with avbtool (key: %s):\n%s', image, key, 345 stdoutdata.rstrip()) 346 347 348def main(): 349 parser = argparse.ArgumentParser( 350 description=__doc__, 351 formatter_class=argparse.RawDescriptionHelpFormatter) 352 parser.add_argument( 353 'target_files', 354 help='the input target_files.zip to be validated') 355 parser.add_argument( 356 '--verity_key', 357 help='the verity public key to verify the bootable images (Verified ' 358 'Boot 1.0), or the vbmeta image (Verified Boot 2.0, aka AVB), where ' 359 'applicable') 360 for partition in common.AVB_PARTITIONS: 361 parser.add_argument( 362 '--avb_' + partition + '_key_path', 363 help='the public or private key in PEM format to verify AVB chained ' 364 'partition of {}'.format(partition)) 365 parser.add_argument( 366 '--verity_key_mincrypt', 367 help='the verity public key in mincrypt format to verify the system ' 368 'images, if target using Verified Boot 1.0') 369 args = parser.parse_args() 370 371 # Unprovided args will have 'None' as the value. 372 options = vars(args) 373 374 logging_format = '%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s' 375 date_format = '%Y/%m/%d %H:%M:%S' 376 logging.basicConfig(level=logging.INFO, format=logging_format, 377 datefmt=date_format) 378 379 logging.info("Unzipping the input target_files.zip: %s", args.target_files) 380 input_tmp = common.UnzipTemp(args.target_files) 381 382 info_dict = common.LoadInfoDict(input_tmp) 383 with zipfile.ZipFile(args.target_files, 'r') as input_zip: 384 ValidateFileConsistency(input_zip, input_tmp, info_dict) 385 386 ValidateInstallRecoveryScript(input_tmp, info_dict) 387 388 ValidateVerifiedBootImages(input_tmp, info_dict, options) 389 390 # TODO: Check if the OTA keys have been properly updated (the ones on /system, 391 # in recovery image). 392 393 logging.info("Done.") 394 395 396if __name__ == '__main__': 397 try: 398 main() 399 finally: 400 common.Cleanup() 401