verify-commits.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2018-2022 The Limenka developers
   3  # Distributed under the MIT software license, see the accompanying
   4  # file COPYING or http://www.opensource.org/licenses/mit-license.php.
   5  """Verify commits against a trusted keys list."""
   6  import argparse
   7  import hashlib
   8  import logging
   9  import os
  10  import subprocess
  11  import sys
  12  import time
  13  
  14  GIT = os.getenv('GIT', 'git')
  15  
  16  def tree_sha512sum(commit='HEAD'):
  17      """Calculate the Tree-sha512 for the commit.
  18  
  19      This is copied from github-merge.py. See https://github.com/limenka/limenka-maintainer-tools."""
  20  
  21      # request metadata for entire tree, recursively
  22      files = []
  23      blob_by_name = {}
  24      for line in subprocess.check_output([GIT, 'ls-tree', '--full-tree', '-r', commit]).splitlines():
  25          name_sep = line.index(b'\t')
  26          metadata = line[:name_sep].split()  # perms, 'blob', blobid
  27          assert metadata[1] == b'blob'
  28          name = line[name_sep + 1:]
  29          files.append(name)
  30          blob_by_name[name] = metadata[2]
  31  
  32      files.sort()
  33      # open connection to git-cat-file in batch mode to request data for all blobs
  34      # this is much faster than launching it per file
  35      p = subprocess.Popen([GIT, 'cat-file', '--batch'], stdout=subprocess.PIPE, stdin=subprocess.PIPE)
  36      overall = hashlib.sha512()
  37      for f in files:
  38          blob = blob_by_name[f]
  39          # request blob
  40          p.stdin.write(blob + b'\n')
  41          p.stdin.flush()
  42          # read header: blob, "blob", size
  43          reply = p.stdout.readline().split()
  44          assert reply[0] == blob and reply[1] == b'blob'
  45          size = int(reply[2])
  46          # hash the blob data
  47          intern = hashlib.sha512()
  48          ptr = 0
  49          while ptr < size:
  50              bs = min(65536, size - ptr)
  51              piece = p.stdout.read(bs)
  52              if len(piece) == bs:
  53                  intern.update(piece)
  54              else:
  55                  raise IOError('Premature EOF reading git cat-file output')
  56              ptr += bs
  57          dig = intern.hexdigest()
  58          assert p.stdout.read(1) == b'\n'  # ignore LF that follows blob data
  59          # update overall hash with file hash
  60          overall.update(dig.encode("utf-8"))
  61          overall.update("  ".encode("utf-8"))
  62          overall.update(f)
  63          overall.update("\n".encode("utf-8"))
  64      p.stdin.close()
  65      if p.wait():
  66          raise IOError('Non-zero return value executing git cat-file')
  67      return overall.hexdigest()
  68  
  69  def main():
  70  
  71      # Enable debug logging if running in CI
  72      if 'CI' in os.environ and os.environ['CI'].lower() == "true":
  73          logging.getLogger().setLevel(logging.DEBUG)
  74  
  75      # Parse arguments
  76      parser = argparse.ArgumentParser(usage='%(prog)s [options] [commit id]')
  77      parser.add_argument('--disable-tree-check', action='store_false', dest='verify_tree', help='disable SHA-512 tree check')
  78      parser.add_argument('--clean-merge', type=float, dest='clean_merge', default=float('inf'), help='Only check clean merge after <NUMBER> days ago (default: %(default)s)', metavar='NUMBER')
  79      parser.add_argument('commit', nargs='?', default='HEAD', help='Check clean merge up to commit <commit>')
  80      args = parser.parse_args()
  81  
  82      # get directory of this program and read data files
  83      dirname = os.path.dirname(os.path.abspath(__file__))
  84      print("Using verify-commits data from " + dirname)
  85      with open(dirname + "/trusted-git-root", "r", encoding="utf8") as f:
  86          verified_root = f.read().splitlines()[0]
  87      with open(dirname + "/trusted-sha512-root-commit", "r", encoding="utf8") as f:
  88          verified_sha512_root = f.read().splitlines()[0]
  89      with open(dirname + "/allow-revsig-commits", "r", encoding="utf8") as f:
  90          revsig_allowed = f.read().splitlines()
  91      with open(dirname + "/allow-unclean-merge-commits", "r", encoding="utf8") as f:
  92          unclean_merge_allowed = f.read().splitlines()
  93      with open(dirname + "/allow-incorrect-sha512-commits", "r", encoding="utf8") as f:
  94          incorrect_sha512_allowed = f.read().splitlines()
  95      with open(dirname + "/trusted-keys", "r", encoding="utf8") as f:
  96          trusted_keys = f.read().splitlines()
  97  
  98      # Set commit and variables
  99      current_commit = args.commit
 100      if ' ' in current_commit:
 101          print("Commit must not contain spaces", file=sys.stderr)
 102          sys.exit(1)
 103      verify_tree = args.verify_tree
 104      no_sha1 = True
 105      prev_commit = ""
 106      initial_commit = current_commit
 107  
 108      # Iterate through commits
 109      while True:
 110  
 111          # Log a message to prevent Travis from timing out
 112          logging.debug("verify-commits: [in-progress] processing commit {}".format(current_commit[:8]))
 113  
 114          if current_commit == verified_root:
 115              print('There is a valid path from "{}" to {} where all commits are signed!'.format(initial_commit, verified_root))
 116              sys.exit(0)
 117          else:
 118              # Make sure this commit isn't older than trusted roots
 119              check_root_older_res = subprocess.run([GIT, "merge-base", "--is-ancestor", verified_root, current_commit])
 120              if check_root_older_res.returncode != 0:
 121                  print(f"\"{current_commit}\" predates the trusted root, stopping!")
 122                  sys.exit(0)
 123  
 124          if verify_tree:
 125              if current_commit == verified_sha512_root:
 126                  print("All Tree-SHA512s matched up to {}".format(verified_sha512_root), file=sys.stderr)
 127                  verify_tree = False
 128                  no_sha1 = False
 129              else:
 130                  # Skip the tree check if we are older than the trusted root
 131                  check_root_older_res = subprocess.run([GIT, "merge-base", "--is-ancestor", verified_sha512_root, current_commit])
 132                  if check_root_older_res.returncode != 0:
 133                      print(f"\"{current_commit}\" predates the trusted SHA512 root, disabling tree verification.")
 134                      verify_tree = False
 135                      no_sha1 = False
 136  
 137  
 138          os.environ['LIMENKA_VERIFY_COMMITS_ALLOW_SHA1'] = "0" if no_sha1 else "1"
 139          allow_revsig = current_commit in revsig_allowed
 140  
 141          # Check that the commit (and parents) was signed with a trusted key
 142          valid_sig = False
 143          verify_res = subprocess.run([GIT, '-c', 'gpg.program={}/gpg.sh'.format(dirname), 'verify-commit', "--raw", current_commit], capture_output=True)
 144          for line in verify_res.stderr.decode().splitlines():
 145              if line.startswith("[GNUPG:] VALIDSIG "):
 146                  key = line.split(" ")[-1]
 147                  valid_sig = key in trusted_keys
 148              elif (line.startswith("[GNUPG:] REVKEYSIG ") or line.startswith("[GNUPG:] EXPKEYSIG ")) and not allow_revsig:
 149                  valid_sig = False
 150                  break
 151          if not valid_sig:
 152              if prev_commit != "":
 153                  print("No parent of {} was signed with a trusted key!".format(prev_commit), file=sys.stderr)
 154                  print("Parents are:", file=sys.stderr)
 155                  parents = subprocess.check_output([GIT, 'show', '-s', '--format=format:%P', prev_commit]).decode('utf8').splitlines()[0].split(' ')
 156                  for parent in parents:
 157                      subprocess.call([GIT, 'show', '-s', parent], stdout=sys.stderr)
 158              else:
 159                  print("{} was not signed with a trusted key!".format(current_commit), file=sys.stderr)
 160              sys.exit(1)
 161  
 162          # Check the Tree-SHA512
 163          if (verify_tree or prev_commit == "") and current_commit not in incorrect_sha512_allowed:
 164              tree_hash = tree_sha512sum(current_commit)
 165              if ("Tree-SHA512: {}".format(tree_hash)) not in subprocess.check_output([GIT, 'show', '-s', '--format=format:%B', current_commit]).decode('utf8').splitlines():
 166                  print("Tree-SHA512 did not match for commit " + current_commit, file=sys.stderr)
 167                  sys.exit(1)
 168  
 169          # Merge commits should only have two parents
 170          parents = subprocess.check_output([GIT, 'show', '-s', '--format=format:%P', current_commit]).decode('utf8').splitlines()[0].split(' ')
 171          if len(parents) > 2:
 172              print("Commit {} is an octopus merge".format(current_commit), file=sys.stderr)
 173              sys.exit(1)
 174  
 175          # Check that the merge commit is clean
 176          commit_time = int(subprocess.check_output([GIT, 'show', '-s', '--format=format:%ct', current_commit]).decode('utf8').splitlines()[0])
 177          check_merge = commit_time > time.time() - args.clean_merge * 24 * 60 * 60  # Only check commits in clean_merge days
 178          allow_unclean = current_commit in unclean_merge_allowed
 179          if len(parents) == 2 and check_merge and not allow_unclean:
 180              current_tree = subprocess.check_output([GIT, 'show', '--format=%T', current_commit]).decode('utf8').splitlines()[0]
 181  
 182              # This merge-tree functionality requires git >= 2.38. The
 183              # --write-tree option was added in order to opt-in to the new
 184              # behavior. Older versions of git will not recognize the option and
 185              # will instead exit with code 128.
 186              try:
 187                  recreated_tree = subprocess.check_output([GIT, "merge-tree", "--write-tree", parents[0], parents[1]]).decode('utf8').splitlines()[0]
 188              except subprocess.CalledProcessError as e:
 189                  if e.returncode == 128:
 190                      print("git v2.38+ is required for this functionality.", file=sys.stderr)
 191                      sys.exit(1)
 192                  else:
 193                      raise e
 194  
 195              if current_tree != recreated_tree:
 196                  print("Merge commit {} is not clean".format(current_commit), file=sys.stderr)
 197                  subprocess.call([GIT, 'diff', recreated_tree, current_tree])
 198                  sys.exit(1)
 199  
 200          prev_commit = current_commit
 201          current_commit = parents[0]
 202  
 203  if __name__ == '__main__':
 204      main()
 205