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