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
40from hashlib import sha1
41from common import IsSparseImage
42
43import common
44import rangelib
45
46
47def _ReadFile(file_name, unpacked_name, round_up=False):
48  """Constructs and returns a File object. Rounds up its size if needed."""
49  assert os.path.exists(unpacked_name)
50  with open(unpacked_name, 'rb') as f:
51    file_data = f.read()
52  file_size = len(file_data)
53  if round_up:
54    file_size_rounded_up = common.RoundUpTo4K(file_size)
55    file_data += b'\0' * (file_size_rounded_up - file_size)
56  return common.File(file_name, file_data)
57
58
59def ValidateFileAgainstSha1(input_tmp, file_name, file_path, expected_sha1):
60  """Check if the file has the expected SHA-1."""
61
62  logging.info('Validating the SHA-1 of %s', file_name)
63  unpacked_name = os.path.join(input_tmp, file_path)
64  assert os.path.exists(unpacked_name)
65  actual_sha1 = _ReadFile(file_name, unpacked_name, False).sha1
66  assert actual_sha1 == expected_sha1, \
67      'SHA-1 mismatches for {}. actual {}, expected {}'.format(
68          file_name, actual_sha1, expected_sha1)
69
70
71def ValidateFileConsistency(input_zip, input_tmp, info_dict):
72  """Compare the files from image files and unpacked folders."""
73
74  def CheckAllFiles(which):
75    logging.info('Checking %s image.', which)
76    path = os.path.join(input_tmp, "IMAGES", which + ".img")
77    if not IsSparseImage(path):
78      logging.info("%s is non-sparse image", which)
79      image = common.GetNonSparseImage(which, input_tmp)
80    else:
81      logging.info("%s is sparse image", which)
82      # Allow having shared blocks when loading the sparse image, because allowing
83      # that doesn't affect the checks below (we will have all the blocks on file,
84      # unless it's skipped due to the holes).
85      image = common.GetSparseImage(which, input_tmp, input_zip, True)
86    prefix = '/' + which
87    for entry in image.file_map:
88      # Skip entries like '__NONZERO-0'.
89      if not entry.startswith(prefix):
90        continue
91
92      # Read the blocks that the file resides. Note that it will contain the
93      # bytes past the file length, which is expected to be padded with '\0's.
94      ranges = image.file_map[entry]
95
96      # Use the original RangeSet if applicable, which includes the shared
97      # blocks. And this needs to happen before checking the monotonicity flag.
98      if ranges.extra.get('uses_shared_blocks'):
99        file_ranges = ranges.extra['uses_shared_blocks']
100      else:
101        file_ranges = ranges
102
103      incomplete = file_ranges.extra.get('incomplete', False)
104      if incomplete:
105        logging.warning('Skipping %s that has incomplete block list', entry)
106        continue
107
108      # If the file has non-monotonic ranges, read each range in order.
109      if not file_ranges.monotonic:
110        h = sha1()
111        for file_range in file_ranges.extra['text_str'].split(' '):
112          for data in image.ReadRangeSet(rangelib.RangeSet(file_range)):
113            h.update(data)
114        blocks_sha1 = h.hexdigest()
115      else:
116        blocks_sha1 = image.RangeSha1(file_ranges)
117
118      # The filename under unpacked directory, such as SYSTEM/bin/sh.
119      unpacked_name = os.path.join(
120          input_tmp, which.upper(), entry[(len(prefix) + 1):])
121      unpacked_file = _ReadFile(entry, unpacked_name, True)
122      file_sha1 = unpacked_file.sha1
123      assert blocks_sha1 == file_sha1, \
124          'file: %s, range: %s, blocks_sha1: %s, file_sha1: %s' % (
125              entry, file_ranges, blocks_sha1, file_sha1)
126
127  logging.info('Validating file consistency.')
128
129  # TODO(b/79617342): Validate non-sparse images.
130  if info_dict.get('extfs_sparse_flag') != '-s':
131    logging.warning('Skipped due to target using non-sparse images')
132    return
133
134  # Verify IMAGES/system.img if applicable.
135  # Some targets are system.img-less.
136  if 'IMAGES/system.img' in input_zip.namelist():
137    CheckAllFiles('system')
138
139  # Verify IMAGES/vendor.img if applicable.
140  if 'VENDOR/' in input_zip.namelist():
141    CheckAllFiles('vendor')
142
143  # Not checking IMAGES/system_other.img since it doesn't have the map file.
144
145
146def ValidateInstallRecoveryScript(input_tmp, info_dict):
147  """Validate the SHA-1 embedded in install-recovery.sh.
148
149  install-recovery.sh is written in common.py and has the following format:
150
151  1. full recovery:
152  ...
153  if ! applypatch --check type:device:size:sha1; then
154    applypatch --flash /vendor/etc/recovery.img \\
155        type:device:size:sha1 && \\
156  ...
157
158  2. recovery from boot:
159  ...
160  if ! applypatch --check type:recovery_device:recovery_size:recovery_sha1; then
161    applypatch [--bonus bonus_args] \\
162        --patch /vendor/recovery-from-boot.p \\
163        --source type:boot_device:boot_size:boot_sha1 \\
164        --target type:recovery_device:recovery_size:recovery_sha1 && \\
165  ...
166
167  For full recovery, we want to calculate the SHA-1 of /vendor/etc/recovery.img
168  and compare it against the one embedded in the script. While for recovery
169  from boot, we want to check the SHA-1 for both recovery.img and boot.img
170  under IMAGES/.
171  """
172
173  board_uses_vendorimage = info_dict.get("board_uses_vendorimage") == "true"
174
175  if board_uses_vendorimage:
176    script_path = 'VENDOR/bin/install-recovery.sh'
177    recovery_img = 'VENDOR/etc/recovery.img'
178  else:
179    script_path = 'SYSTEM/vendor/bin/install-recovery.sh'
180    recovery_img = 'SYSTEM/vendor/etc/recovery.img'
181
182  if not os.path.exists(os.path.join(input_tmp, script_path)):
183    logging.info('%s does not exist in input_tmp', script_path)
184    return
185
186  logging.info('Checking %s', script_path)
187  with open(os.path.join(input_tmp, script_path), 'r') as script:
188    lines = script.read().strip().split('\n')
189  assert len(lines) >= 10
190  check_cmd = re.search(r'if ! applypatch --check (\w+:.+:\w+:\w+);',
191                        lines[1].strip())
192  check_partition = check_cmd.group(1)
193  assert len(check_partition.split(':')) == 4
194
195  full_recovery_image = info_dict.get("full_recovery_image") == "true"
196  if full_recovery_image:
197    assert len(lines) == 10, "Invalid line count: {}".format(lines)
198
199    # Expect something like "EMMC:/dev/block/recovery:28:5f9c..62e3".
200    target = re.search(r'--target (.+) &&', lines[4].strip())
201    assert target is not None, \
202        "Failed to parse target line \"{}\"".format(lines[4])
203    flash_partition = target.group(1)
204
205    # Check we have the same recovery target in the check and flash commands.
206    assert check_partition == flash_partition, \
207        "Mismatching targets: {} vs {}".format(
208            check_partition, flash_partition)
209
210    # Validate the SHA-1 of the recovery image.
211    recovery_sha1 = flash_partition.split(':')[3]
212    ValidateFileAgainstSha1(
213        input_tmp, 'recovery.img', recovery_img, recovery_sha1)
214  else:
215    assert len(lines) == 11, "Invalid line count: {}".format(lines)
216
217    # --source boot_type:boot_device:boot_size:boot_sha1
218    source = re.search(r'--source (\w+:.+:\w+:\w+) \\', lines[4].strip())
219    assert source is not None, \
220        "Failed to parse source line \"{}\"".format(lines[4])
221
222    source_partition = source.group(1)
223    source_info = source_partition.split(':')
224    assert len(source_info) == 4, \
225        "Invalid source partition: {}".format(source_partition)
226    ValidateFileAgainstSha1(input_tmp, file_name='boot.img',
227                            file_path='IMAGES/boot.img',
228                            expected_sha1=source_info[3])
229
230    # --target recovery_type:recovery_device:recovery_size:recovery_sha1
231    target = re.search(r'--target (\w+:.+:\w+:\w+) && \\', lines[5].strip())
232    assert target is not None, \
233        "Failed to parse target line \"{}\"".format(lines[5])
234    target_partition = target.group(1)
235
236    # Check we have the same recovery target in the check and patch commands.
237    assert check_partition == target_partition, \
238        "Mismatching targets: {} vs {}".format(
239            check_partition, target_partition)
240
241    recovery_info = target_partition.split(':')
242    assert len(recovery_info) == 4, \
243        "Invalid target partition: {}".format(target_partition)
244    ValidateFileAgainstSha1(input_tmp, file_name='recovery.img',
245                            file_path='IMAGES/recovery.img',
246                            expected_sha1=recovery_info[3])
247
248  logging.info('Done checking %s', script_path)
249
250
251# Symlink files in `src` to `dst`, if the files do not
252# already exists in `dst` directory.
253def symlinkIfNotExists(src, dst):
254  if not os.path.isdir(src):
255    return
256  for filename in os.listdir(src):
257    if os.path.exists(os.path.join(dst, filename)):
258      continue
259    os.symlink(os.path.join(src, filename), os.path.join(dst, filename))
260
261
262def ValidatePartitionFingerprints(input_tmp, info_dict):
263  build_info = common.BuildInfo(info_dict)
264  # Expected format:
265  #  Prop: com.android.build.vendor.fingerprint -> 'generic/aosp_cf_x86_64_phone/vsoc_x86_64:S/AOSP.MASTER/7335886:userdebug/test-keys'
266  #  Prop: com.android.build.vendor_boot.fingerprint -> 'generic/aosp_cf_x86_64_phone/vsoc_x86_64:S/AOSP.MASTER/7335886:userdebug/test-keys'
267  p = re.compile(
268      r"Prop: com.android.build.(?P<partition>\w+).fingerprint -> '(?P<fingerprint>[\w\/:\.-]+)'")
269  for vbmeta_partition in ["vbmeta", "vbmeta_system"]:
270    image = os.path.join(input_tmp, "IMAGES", vbmeta_partition + ".img")
271    if not os.path.exists(image):
272      assert vbmeta_partition != "vbmeta",\
273          "{} is a required partition for AVB.".format(
274              vbmeta_partition)
275      logging.info("vb partition %s not present, skipping", vbmeta_partition)
276      continue
277
278    output = common.RunAndCheckOutput(
279        [info_dict["avb_avbtool"], "info_image", "--image", image])
280    matches = p.findall(output)
281    for (partition, fingerprint) in matches:
282      actual_fingerprint = build_info.GetPartitionFingerprint(
283          partition)
284      if actual_fingerprint is None:
285        logging.warning(
286            "Failed to get fingerprint for partition %s", partition)
287        continue
288      assert fingerprint == actual_fingerprint, "Fingerprint mismatch for partition {}, expected: {} actual: {}".format(
289          partition, fingerprint, actual_fingerprint)
290
291
292def ValidateVerifiedBootImages(input_tmp, info_dict, options):
293  """Validates the Verified Boot related images.
294
295  For Verified Boot 1.0, it verifies the signatures of the bootable images
296  (boot/recovery etc), as well as the dm-verity metadata in system images
297  (system/vendor/product). For Verified Boot 2.0, it calls avbtool to verify
298  vbmeta.img, which in turn verifies all the descriptors listed in vbmeta.
299
300  Args:
301    input_tmp: The top-level directory of unpacked target-files.zip.
302    info_dict: The loaded info dict.
303    options: A dict that contains the user-supplied public keys to be used for
304        image verification. In particular, 'verity_key' is used to verify the
305        bootable images in VB 1.0, and the vbmeta image in VB 2.0, where
306        applicable. 'verity_key_mincrypt' will be used to verify the system
307        images in VB 1.0.
308
309  Raises:
310    AssertionError: On any verification failure.
311  """
312  # See bug 159299583
313  # After commit 5277d1015, some images (e.g. acpio.img and tos.img) are no
314  # longer copied from RADIO to the IMAGES folder. But avbtool assumes that
315  # images are in IMAGES folder. So we symlink them.
316  symlinkIfNotExists(os.path.join(input_tmp, "RADIO"),
317                     os.path.join(input_tmp, "IMAGES"))
318  # Verified boot 1.0 (images signed with boot_signer and verity_signer).
319  if info_dict.get('boot_signer') == 'true':
320    logging.info('Verifying Verified Boot images...')
321
322    # Verify the boot/recovery images (signed with boot_signer), against the
323    # given X.509 encoded pubkey (or falling back to the one in the info_dict if
324    # none given).
325    verity_key = options['verity_key']
326    if verity_key is None:
327      verity_key = info_dict['verity_key'] + '.x509.pem'
328    for image in ('boot.img', 'recovery.img', 'recovery-two-step.img'):
329      if image == 'recovery-two-step.img':
330        image_path = os.path.join(input_tmp, 'OTA', image)
331      else:
332        image_path = os.path.join(input_tmp, 'IMAGES', image)
333      if not os.path.exists(image_path):
334        continue
335
336      cmd = ['boot_signer', '-verify', image_path, '-certificate', verity_key]
337      proc = common.Run(cmd)
338      stdoutdata, _ = proc.communicate()
339      assert proc.returncode == 0, \
340          'Failed to verify {} with boot_signer:\n{}'.format(image, stdoutdata)
341      logging.info(
342          'Verified %s with boot_signer (key: %s):\n%s', image, verity_key,
343          stdoutdata.rstrip())
344
345  # Verify verity signed system images in Verified Boot 1.0. Note that not using
346  # 'elif' here, since 'boot_signer' and 'verity' are not bundled in VB 1.0.
347  if info_dict.get('verity') == 'true':
348    # First verify that the verity key is built into the root image (regardless
349    # of system-as-root).
350    verity_key_mincrypt = os.path.join(input_tmp, 'ROOT', 'verity_key')
351    assert os.path.exists(verity_key_mincrypt), 'Missing verity_key'
352
353    # Verify /verity_key matches the one given via command line, if any.
354    if options['verity_key_mincrypt'] is None:
355      logging.warn(
356          'Skipped checking the content of /verity_key, as the key file not '
357          'provided. Use --verity_key_mincrypt to specify.')
358    else:
359      expected_key = options['verity_key_mincrypt']
360      assert filecmp.cmp(expected_key, verity_key_mincrypt, shallow=False), \
361          "Mismatching mincrypt verity key files"
362      logging.info('Verified the content of /verity_key')
363
364    verity_key_ramdisk = os.path.join(
365        input_tmp, 'BOOT', 'RAMDISK', 'verity_key')
366    assert os.path.exists(
367        verity_key_ramdisk), 'Missing verity_key in ramdisk'
368
369    assert filecmp.cmp(
370        verity_key_mincrypt, verity_key_ramdisk, shallow=False), \
371        'Mismatching verity_key files in root and ramdisk'
372    logging.info('Verified the content of /verity_key in ramdisk')
373
374    # Then verify the verity signed system/vendor/product images, against the
375    # verity pubkey in mincrypt format.
376    for image in ('system.img', 'vendor.img', 'product.img'):
377      image_path = os.path.join(input_tmp, 'IMAGES', image)
378
379      # We are not checking if the image is actually enabled via info_dict (e.g.
380      # 'system_verity_block_device=...'). Because it's most likely a bug that
381      # skips signing some of the images in signed target-files.zip, while
382      # having the top-level verity flag enabled.
383      if not os.path.exists(image_path):
384        continue
385
386      cmd = ['verity_verifier', image_path, '-mincrypt', verity_key_mincrypt]
387      proc = common.Run(cmd)
388      stdoutdata, _ = proc.communicate()
389      assert proc.returncode == 0, \
390          'Failed to verify {} with verity_verifier (key: {}):\n{}'.format(
391              image, verity_key_mincrypt, stdoutdata)
392      logging.info(
393          'Verified %s with verity_verifier (key: %s):\n%s', image,
394          verity_key_mincrypt, stdoutdata.rstrip())
395
396  # Handle the case of Verified Boot 2.0 (AVB).
397  if info_dict.get("avb_building_vbmeta_image") == "true":
398    logging.info('Verifying Verified Boot 2.0 (AVB) images...')
399
400    key = options['verity_key']
401    if key is None:
402      key = info_dict['avb_vbmeta_key_path']
403
404    ValidatePartitionFingerprints(input_tmp, info_dict)
405
406    # avbtool verifies all the images that have descriptors listed in vbmeta.
407    # Using `--follow_chain_partitions` so it would additionally verify chained
408    # vbmeta partitions (e.g. vbmeta_system).
409    image = os.path.join(input_tmp, 'IMAGES', 'vbmeta.img')
410    cmd = [info_dict['avb_avbtool'], 'verify_image', '--image', image,
411           '--follow_chain_partitions']
412
413    # Custom images.
414    custom_partitions = info_dict.get(
415        "avb_custom_images_partition_list", "").strip().split()
416
417    # Append the args for chained partitions if any.
418    for partition in (common.AVB_PARTITIONS + common.AVB_VBMETA_PARTITIONS +
419                      tuple(custom_partitions)):
420      key_name = 'avb_' + partition + '_key_path'
421      if info_dict.get(key_name) is not None:
422        if info_dict.get('ab_update') != 'true' and partition == 'recovery':
423          continue
424
425        # Use the key file from command line if specified; otherwise fall back
426        # to the one in info dict.
427        key_file = options.get(key_name, info_dict[key_name])
428        chained_partition_arg = common.GetAvbChainedPartitionArg(
429            partition, info_dict, key_file)
430        cmd.extend(['--expected_chain_partition',
431                    chained_partition_arg.to_string()])
432
433    # Handle the boot image with a non-default name, e.g. boot-5.4.img
434    boot_images = info_dict.get("boot_images")
435    if boot_images:
436      # we used the 1st boot image to generate the vbmeta. Rename the filename
437      # to boot.img so that avbtool can find it correctly.
438      first_image_name = boot_images.split()[0]
439      first_image_path = os.path.join(input_tmp, 'IMAGES', first_image_name)
440      assert os.path.isfile(first_image_path)
441      renamed_boot_image_path = os.path.join(input_tmp, 'IMAGES', 'boot.img')
442      os.rename(first_image_path, renamed_boot_image_path)
443
444    proc = common.Run(cmd)
445    stdoutdata, _ = proc.communicate()
446    assert proc.returncode == 0, \
447        'Failed to verify {} with avbtool (key: {}):\n{}'.format(
448            image, key, stdoutdata)
449
450    logging.info(
451        'Verified %s with avbtool (key: %s):\n%s', image, key,
452        stdoutdata.rstrip())
453
454    # avbtool verifies recovery image for non-A/B devices.
455    if (info_dict.get('ab_update') != 'true' and
456            info_dict.get('no_recovery') != 'true'):
457      image = os.path.join(input_tmp, 'IMAGES', 'recovery.img')
458      key = info_dict['avb_recovery_key_path']
459      cmd = [info_dict['avb_avbtool'], 'verify_image', '--image', image,
460             '--key', key]
461      proc = common.Run(cmd)
462      stdoutdata, _ = proc.communicate()
463      assert proc.returncode == 0, \
464          'Failed to verify {} with avbtool (key: {}):\n{}'.format(
465              image, key, stdoutdata)
466      logging.info(
467          'Verified %s with avbtool (key: %s):\n%s', image, key,
468          stdoutdata.rstrip())
469
470
471def CheckDataInconsistency(lines):
472  build_prop = {}
473  for line in lines:
474    if line.startswith("import") or line.startswith("#"):
475      continue
476    if "=" not in line:
477      continue
478
479    key, value = line.rstrip().split("=", 1)
480    if key in build_prop:
481      logging.info("Duplicated key found for {}".format(key))
482      if value != build_prop[key]:
483        logging.error("Key {} is defined twice with different values {} vs {}"
484                      .format(key, value, build_prop[key]))
485        return key
486    build_prop[key] = value
487
488
489def CheckBuildPropDuplicity(input_tmp):
490  """Check all buld.prop files inside directory input_tmp, raise error
491  if they contain duplicates"""
492
493  if not os.path.isdir(input_tmp):
494    raise ValueError("Expect {} to be a directory".format(input_tmp))
495  for name in os.listdir(input_tmp):
496    if not name.isupper():
497      continue
498    for prop_file in ['build.prop', 'etc/build.prop']:
499      path = os.path.join(input_tmp, name, prop_file)
500      if not os.path.exists(path):
501        continue
502      logging.info("Checking {}".format(path))
503      with open(path, 'r') as fp:
504        dupKey = CheckDataInconsistency(fp.readlines())
505        if dupKey:
506          raise ValueError("{} contains duplicate keys for {}".format(
507              path, dupKey))
508
509
510def main():
511  parser = argparse.ArgumentParser(
512      description=__doc__,
513      formatter_class=argparse.RawDescriptionHelpFormatter)
514  parser.add_argument(
515      'target_files',
516      help='the input target_files.zip to be validated')
517  parser.add_argument(
518      '--verity_key',
519      help='the verity public key to verify the bootable images (Verified '
520           'Boot 1.0), or the vbmeta image (Verified Boot 2.0, aka AVB), where '
521           'applicable')
522  for partition in common.AVB_PARTITIONS + common.AVB_VBMETA_PARTITIONS:
523    parser.add_argument(
524        '--avb_' + partition + '_key_path',
525        help='the public or private key in PEM format to verify AVB chained '
526             'partition of {}'.format(partition))
527  parser.add_argument(
528      '--verity_key_mincrypt',
529      help='the verity public key in mincrypt format to verify the system '
530           'images, if target using Verified Boot 1.0')
531  args = parser.parse_args()
532
533  # Unprovided args will have 'None' as the value.
534  options = vars(args)
535
536  logging_format = '%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s'
537  date_format = '%Y/%m/%d %H:%M:%S'
538  logging.basicConfig(level=logging.INFO, format=logging_format,
539                      datefmt=date_format)
540
541  logging.info("Unzipping the input target_files.zip: %s", args.target_files)
542  input_tmp = common.UnzipTemp(args.target_files)
543
544  info_dict = common.LoadInfoDict(input_tmp)
545  with zipfile.ZipFile(args.target_files, 'r', allowZip64=True) as input_zip:
546    ValidateFileConsistency(input_zip, input_tmp, info_dict)
547
548  CheckBuildPropDuplicity(input_tmp)
549
550  ValidateInstallRecoveryScript(input_tmp, info_dict)
551
552  ValidateVerifiedBootImages(input_tmp, info_dict, options)
553
554  # TODO: Check if the OTA keys have been properly updated (the ones on /system,
555  # in recovery image).
556
557  logging.info("Done.")
558
559
560if __name__ == '__main__':
561  try:
562    main()
563  finally:
564    common.Cleanup()
565