verify.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2020-present The Bitcoin Core developers
   3  # Distributed under the MIT software license, see the accompanying
   4  # file COPYING or http://www.opensource.org/licenses/mit-license.php.
   5  """Script for verifying Bitcoin Core release binaries.
   6  
   7  This script attempts to download the sum file SHA256SUMS and corresponding
   8  signature file SHA256SUMS.asc from bitcoincore.org and bitcoin.org and
   9  compares them.
  10  
  11  The sum-signature file is signed by a number of builder keys. This script
  12  ensures that there is a minimum threshold of signatures from pubkeys that
  13  we trust. This trust is articulated on the basis of configuration options
  14  here, but by default is based upon local GPG trust settings.
  15  
  16  The builder keys are available in the guix.sigs repo:
  17  
  18      https://github.com/bitcoin-core/guix.sigs/tree/main/builder-keys
  19  
  20  If a minimum good, trusted signature threshold is met on the sum file, we then
  21  download the files specified in SHA256SUMS, and check if the hashes of these
  22  files match those that are specified. The script returns 0 if everything passes
  23  the checks. It returns 1 if either the signature check or the hash check
  24  doesn't pass. If an error occurs the return value is >= 2.
  25  
  26  Logging output goes to stderr and final binary verification data goes to stdout.
  27  
  28  JSON output can by obtained by setting env BINVERIFY_JSON=1.
  29  """
  30  import argparse
  31  import difflib
  32  import json
  33  import logging
  34  import os
  35  import subprocess
  36  import typing as t
  37  import re
  38  import sys
  39  import shutil
  40  import tempfile
  41  import textwrap
  42  import enum
  43  from hashlib import sha256
  44  from pathlib import PurePath, Path
  45  
  46  # The primary host; this will fail if we can't retrieve files from here.
  47  HOST1 = "https://bitcoincore.org"
  48  HOST2 = "https://bitcoin.org"
  49  VERSIONPREFIX = "bitcoin-core-"
  50  SUMS_FILENAME = 'SHA256SUMS'
  51  SIGNATUREFILENAME = f"{SUMS_FILENAME}.asc"
  52  
  53  
  54  class ReturnCode(enum.IntEnum):
  55      SUCCESS = 0
  56      INTEGRITY_FAILURE = 1
  57      FILE_GET_FAILED = 4
  58      FILE_MISSING_FROM_ONE_HOST = 5
  59      FILES_NOT_EQUAL = 6
  60      NO_BINARIES_MATCH = 7
  61      NOT_ENOUGH_GOOD_SIGS = 9
  62      BINARY_DOWNLOAD_FAILED = 10
  63      BAD_VERSION = 11
  64  
  65  
  66  def set_up_logger(is_verbose: bool = True) -> logging.Logger:
  67      """Set up a logger that writes to stderr."""
  68      log = logging.getLogger(__name__)
  69      log.setLevel(logging.INFO if is_verbose else logging.WARNING)
  70      console = logging.StreamHandler(sys.stderr)  # log to stderr
  71      console.setLevel(logging.DEBUG)
  72      formatter = logging.Formatter('[%(levelname)s] %(message)s')
  73      console.setFormatter(formatter)
  74      log.addHandler(console)
  75      return log
  76  
  77  
  78  log = set_up_logger()
  79  
  80  
  81  def indent(output: str) -> str:
  82      return textwrap.indent(output, '  ')
  83  
  84  
  85  def bool_from_env(key, default=False) -> bool:
  86      if key not in os.environ:
  87          return default
  88      raw = os.environ[key]
  89  
  90      if raw.lower() in ('1', 'true'):
  91          return True
  92      elif raw.lower() in ('0', 'false'):
  93          return False
  94      raise ValueError(f"Unrecognized environment value {key}={raw!r}")
  95  
  96  
  97  VERSION_FORMAT = "<major>.<minor>[.<patch>][-rc[0-9]][-platform]"
  98  VERSION_EXAMPLE = "22.0 or 23.1-rc1-darwin.dmg or 27.0-x86_64-linux-gnu"
  99  
 100  def parse_version_string(version_str):
 101      # "<version>[-rcN][-platform]"
 102      version_base, _, platform = version_str.partition('-')
 103      rc = ""
 104      if platform.startswith("rc"): # "<version>-rcN[-platform]"
 105          rc, _, platform = platform.partition('-')
 106      # else "<version>" or "<version>-platform"
 107  
 108      return version_base, rc, platform
 109  
 110  
 111  def download_with_wget(remote_file, local_file):
 112      result = subprocess.run(['wget', '-O', local_file, remote_file],
 113                              stderr=subprocess.STDOUT, stdout=subprocess.PIPE)
 114      return result.returncode == 0, result.stdout.decode().rstrip()
 115  
 116  
 117  def verify_with_gpg(
 118      filename,
 119      signature_filename,
 120      output_filename: t.Optional[str] = None
 121  ) -> tuple[int, str]:
 122      with tempfile.NamedTemporaryFile() as status_file:
 123          args = [
 124              'gpg', '--yes', '--verify', '--verify-options', 'show-primary-uid-only', "--status-file", status_file.name,
 125              '--output', output_filename if output_filename else '', signature_filename, filename]
 126  
 127          env = dict(os.environ, LANGUAGE='en')
 128          result = subprocess.run(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, env=env)
 129  
 130          gpg_data = status_file.read().decode().rstrip()
 131  
 132      log.debug(f'Result from GPG ({result.returncode}): {result.stdout.decode()}')
 133      log.debug(f"{gpg_data}")
 134      return result.returncode, gpg_data
 135  
 136  
 137  class SigData:
 138      """GPG signature data as parsed from GPG stdout."""
 139      def __init__(self):
 140          self.key = None
 141          self.name = ""
 142          self.trusted = False
 143          self.status = ""
 144  
 145      def __bool__(self):
 146          return self.key is not None
 147  
 148      def __repr__(self):
 149          return (
 150              "SigData(%r, %r, trusted=%s, status=%r)" %
 151              (self.key, self.name, self.trusted, self.status))
 152  
 153  
 154  def parse_gpg_result(
 155      output: list[str]
 156  ) -> tuple[list[SigData], list[SigData], list[SigData]]:
 157      """Returns good, unknown, and bad signatures from GPG stdout."""
 158      good_sigs: list[SigData] = []
 159      unknown_sigs: list[SigData] = []
 160      bad_sigs: list[SigData] = []
 161      total_resolved_sigs = 0
 162  
 163      # Ensure that all lines we match on include a prefix that prevents malicious input
 164      # from fooling the parser.
 165      def line_begins_with(patt: str, line: str) -> t.Optional[re.Match]:
 166          return re.match(r'^(\[GNUPG:\])\s+' + patt, line)
 167  
 168      curr_sigs = unknown_sigs
 169      curr_sigdata = SigData()
 170  
 171      for line in output:
 172          if line_begins_with(r"NEWSIG(?:\s|$)", line):
 173              total_resolved_sigs += 1
 174              if curr_sigdata:
 175                  curr_sigs.append(curr_sigdata)
 176                  curr_sigdata = SigData()
 177              newsig_split = line.split()
 178              if len(newsig_split) == 3:
 179                  curr_sigdata.name = newsig_split[2]
 180  
 181          elif line_begins_with(r"GOODSIG(?:\s|$)", line):
 182              curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
 183              curr_sigs = good_sigs
 184  
 185          elif line_begins_with(r"EXPKEYSIG(?:\s|$)", line):
 186              curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
 187              curr_sigs = good_sigs
 188              curr_sigdata.status = "expired"
 189  
 190          elif line_begins_with(r"REVKEYSIG(?:\s|$)", line):
 191              curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
 192              curr_sigs = good_sigs
 193              curr_sigdata.status = "revoked"
 194  
 195          elif line_begins_with(r"BADSIG(?:\s|$)", line):
 196              curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
 197              curr_sigs = bad_sigs
 198  
 199          elif line_begins_with(r"ERRSIG(?:\s|$)", line):
 200              curr_sigdata.key, _, _, _, _, _ = line.split()[2:8]
 201              curr_sigs = unknown_sigs
 202  
 203          elif line_begins_with(r"TRUST_(UNDEFINED|NEVER)(?:\s|$)", line):
 204              curr_sigdata.trusted = False
 205  
 206          elif line_begins_with(r"TRUST_(MARGINAL|FULLY|ULTIMATE)(?:\s|$)", line):
 207              curr_sigdata.trusted = True
 208  
 209      # The last one won't have been added, so add it now
 210      assert curr_sigdata
 211      curr_sigs.append(curr_sigdata)
 212  
 213      all_found = len(good_sigs + bad_sigs + unknown_sigs)
 214      if all_found != total_resolved_sigs:
 215          raise RuntimeError(
 216              f"failed to evaluate all signatures: found {all_found} "
 217              f"but expected {total_resolved_sigs}")
 218  
 219      return (good_sigs, unknown_sigs, bad_sigs)
 220  
 221  
 222  def files_are_equal(filename1, filename2):
 223      with open(filename1, 'rb') as file1:
 224          contents1 = file1.read()
 225      with open(filename2, 'rb') as file2:
 226          contents2 = file2.read()
 227      eq = contents1 == contents2
 228  
 229      if not eq:
 230          with open(filename1, 'r') as f1, \
 231                  open(filename2, 'r') as f2:
 232              f1lines = f1.readlines()
 233              f2lines = f2.readlines()
 234  
 235              diff = indent(
 236                  ''.join(difflib.unified_diff(f1lines, f2lines)))
 237              log.warning(f"found diff in files ({filename1}, {filename2}):\n{diff}\n")
 238  
 239      return eq
 240  
 241  
 242  def get_files_from_hosts_and_compare(
 243      hosts: list[str], path: str, filename: str, require_all: bool = False
 244  ) -> ReturnCode:
 245      """
 246      Retrieve the same file from a number of hosts and ensure they have the same contents.
 247      The first host given will be treated as the "primary" host, and is required to succeed.
 248  
 249      Args:
 250          filename: for writing the file locally.
 251      """
 252      assert len(hosts) > 1
 253      primary_host = hosts[0]
 254      other_hosts = hosts[1:]
 255      got_files = []
 256  
 257      def join_url(host: str) -> str:
 258          return host.rstrip('/') + '/' + path.lstrip('/')
 259  
 260      url = join_url(primary_host)
 261      success, output = download_with_wget(url, filename)
 262      if not success:
 263          log.error(
 264              f"couldn't fetch file ({url}). "
 265              "Have you specified the version number in the following format?\n"
 266              f"{VERSION_FORMAT} "
 267              f"(example: {VERSION_EXAMPLE})\n"
 268              f"wget output:\n{indent(output)}")
 269          return ReturnCode.FILE_GET_FAILED
 270      else:
 271          log.info(f"got file {url} as {filename}")
 272          got_files.append(filename)
 273  
 274      for i, host in enumerate(other_hosts):
 275          url = join_url(host)
 276          fname = filename + f'.{i + 2}'
 277          success, output = download_with_wget(url, fname)
 278  
 279          if require_all and not success:
 280              log.error(
 281                  f"{host} failed to provide file ({url}), but {primary_host} did?\n"
 282                  f"wget output:\n{indent(output)}")
 283              return ReturnCode.FILE_MISSING_FROM_ONE_HOST
 284          elif not success:
 285              log.warning(
 286                  f"{host} failed to provide file ({url}). "
 287                  f"Continuing based solely upon {primary_host}.")
 288          else:
 289              log.info(f"got file {url} as {fname}")
 290              got_files.append(fname)
 291  
 292      for i, got_file in enumerate(got_files):
 293          if got_file == got_files[-1]:
 294              break  # break on last file, nothing after it to compare to
 295  
 296          compare_to = got_files[i + 1]
 297          if not files_are_equal(got_file, compare_to):
 298              log.error(f"files not equal: {got_file} and {compare_to}")
 299              return ReturnCode.FILES_NOT_EQUAL
 300  
 301      return ReturnCode.SUCCESS
 302  
 303  
 304  def check_multisig(sums_file: str, sigfilename: str, args: argparse.Namespace) -> tuple[int, str, list[SigData], list[SigData], list[SigData]]:
 305      # check signature
 306      #
 307      # We don't write output to a file because this command will almost certainly
 308      # fail with GPG exit code '2' (and so not writing to --output) because of the
 309      # likely presence of multiple untrusted signatures.
 310      retval, output = verify_with_gpg(sums_file, sigfilename)
 311  
 312      if args.verbose:
 313          log.info(f"gpg output:\n{indent(output)}")
 314  
 315      good, unknown, bad = parse_gpg_result(output.splitlines())
 316  
 317      if unknown and args.import_keys:
 318          # Retrieve unknown keys and then try GPG again.
 319          for unsig in unknown:
 320              if prompt_yn(f" ? Retrieve key {unsig.key} ({unsig.name})? (y/N) "):
 321                  ran = subprocess.run(
 322                      ["gpg", "--keyserver", args.keyserver, "--recv-keys", unsig.key])
 323  
 324                  if ran.returncode != 0:
 325                      log.warning(f"failed to retrieve key {unsig.key}")
 326  
 327          # Reparse the GPG output now that we have more keys
 328          retval, output = verify_with_gpg(sums_file, sigfilename)
 329          good, unknown, bad = parse_gpg_result(output.splitlines())
 330  
 331      return retval, output, good, unknown, bad
 332  
 333  
 334  def prompt_yn(prompt) -> bool:
 335      """Return true if the user inputs 'y'."""
 336      got = ''
 337      while got not in ['y', 'n']:
 338          got = input(prompt).lower()
 339      return got == 'y'
 340  
 341  def verify_shasums_signature(
 342      signature_file_path: str, sums_file_path: str, args: argparse.Namespace
 343  ) -> tuple[
 344     ReturnCode, list[SigData], list[SigData], list[SigData], list[SigData]
 345  ]:
 346      min_good_sigs = args.min_good_sigs
 347      gpg_allowed_codes = [0, 2]  # 2 is returned when untrusted signatures are present.
 348  
 349      gpg_retval, gpg_output, good, unknown, bad = check_multisig(sums_file_path, signature_file_path, args)
 350  
 351      if gpg_retval not in gpg_allowed_codes:
 352          if gpg_retval == 1:
 353              log.critical(f"Bad signature (code: {gpg_retval}).")
 354          else:
 355              log.critical(f"unexpected GPG exit code ({gpg_retval})")
 356  
 357          log.error(f"gpg output:\n{indent(gpg_output)}")
 358          return (ReturnCode.INTEGRITY_FAILURE, [], [], [], [])
 359  
 360      # Decide which keys we trust, though not "trust" in the GPG sense, but rather
 361      # which pubkeys convince us that this sums file is legitimate. In other words,
 362      # which pubkeys within the Bitcoin community do we trust for the purposes of
 363      # binary verification?
 364      trusted_keys = set()
 365      if args.trusted_keys:
 366          trusted_keys |= set(args.trusted_keys.split(','))
 367  
 368      # Tally signatures and make sure we have enough goods to fulfill
 369      # our threshold.
 370      good_trusted = [sig for sig in good if sig.trusted or sig.key in trusted_keys]
 371      good_untrusted = [sig for sig in good if sig not in good_trusted]
 372      num_trusted = len(good_trusted) + len(good_untrusted)
 373      log.info(f"got {num_trusted} good signatures")
 374  
 375      if num_trusted < min_good_sigs:
 376          log.info("Maybe you need to import "
 377                    f"(`gpg --keyserver {args.keyserver} --recv-keys <key-id>`) "
 378                    "some of the following keys: ")
 379          log.info('')
 380          for sig in unknown:
 381              log.info(f"    {sig.key} ({sig.name})")
 382          log.info('')
 383          log.error(
 384              "not enough trusted sigs to meet threshold "
 385              f"({num_trusted} vs. {min_good_sigs})")
 386  
 387          return (ReturnCode.NOT_ENOUGH_GOOD_SIGS, [], [], [], [])
 388  
 389      for sig in good_trusted:
 390          log.info(f"GOOD SIGNATURE: {sig}")
 391  
 392      for sig in good_untrusted:
 393          log.info(f"GOOD SIGNATURE (untrusted): {sig}")
 394  
 395      for sig in [sig for sig in good if sig.status == 'expired']:
 396          log.warning(f"key {sig.key} for {sig.name} is expired")
 397  
 398      for sig in bad:
 399          log.warning(f"BAD SIGNATURE: {sig}")
 400  
 401      for sig in unknown:
 402          log.warning(f"UNKNOWN SIGNATURE: {sig}")
 403  
 404      return (ReturnCode.SUCCESS, good_trusted, good_untrusted, unknown, bad)
 405  
 406  
 407  def parse_sums_file(sums_file_path: str, filename_filter: list[str]) -> list[list[str]]:
 408      # extract hashes/filenames of binaries to verify from hash file;
 409      # each line has the following format: "<hash> <binary_filename>"
 410      with open(sums_file_path, 'r') as hash_file:
 411          return [line.split()[:2] for line in hash_file if len(filename_filter) == 0 or any(f in line for f in filename_filter)]
 412  
 413  
 414  def verify_binary_hashes(hashes_to_verify: list[list[str]]) -> tuple[ReturnCode, dict[str, str]]:
 415      offending_files = []
 416      files_to_hashes = {}
 417  
 418      for hash_expected, binary_filename in hashes_to_verify:
 419          with open(binary_filename, 'rb') as binary_file:
 420              hash_calculated = sha256(binary_file.read()).hexdigest()
 421          if hash_calculated != hash_expected:
 422              offending_files.append(binary_filename)
 423          else:
 424              files_to_hashes[binary_filename] = hash_calculated
 425  
 426      if offending_files:
 427          joined_files = '\n'.join(offending_files)
 428          log.critical(
 429              "Hashes don't match.\n"
 430              f"Offending files:\n{joined_files}")
 431          return (ReturnCode.INTEGRITY_FAILURE, files_to_hashes)
 432  
 433      return (ReturnCode.SUCCESS, files_to_hashes)
 434  
 435  
 436  def verify_published_handler(args: argparse.Namespace) -> ReturnCode:
 437      WORKINGDIR = Path(tempfile.gettempdir()) / f"bitcoin_verify_binaries.{args.version}"
 438  
 439      def cleanup():
 440          log.info("cleaning up files")
 441          os.chdir(Path.home())
 442          shutil.rmtree(WORKINGDIR)
 443  
 444      # determine remote dir dependent on provided version string
 445      try:
 446          version_base, version_rc, os_filter = parse_version_string(args.version)
 447          version_tuple = [int(i) for i in version_base.split('.')]
 448      except Exception as e:
 449          log.debug(e)
 450          log.error(f"unable to parse version; expected format is {VERSION_FORMAT}")
 451          log.error(f"  e.g. {VERSION_EXAMPLE}")
 452          return ReturnCode.BAD_VERSION
 453  
 454      remote_dir = f"/bin/{VERSIONPREFIX}{version_base}/"
 455      if version_rc:
 456          remote_dir += f"test.{version_rc}/"
 457      remote_sigs_path = remote_dir + SIGNATUREFILENAME
 458      remote_sums_path = remote_dir + SUMS_FILENAME
 459  
 460      # create working directory
 461      os.makedirs(WORKINGDIR, exist_ok=True)
 462      os.chdir(WORKINGDIR)
 463  
 464      hosts = [HOST1, HOST2]
 465  
 466      got_sig_status = get_files_from_hosts_and_compare(
 467          hosts, remote_sigs_path, SIGNATUREFILENAME, args.require_all_hosts)
 468      if got_sig_status != ReturnCode.SUCCESS:
 469          return got_sig_status
 470  
 471      # Multi-sig verification is available after 22.0.
 472      if version_tuple[0] < 22:
 473          log.error("Version too old - single sig not supported. Use a previous "
 474                    "version of this script from the repo.")
 475          return ReturnCode.BAD_VERSION
 476  
 477      got_sums_status = get_files_from_hosts_and_compare(
 478          hosts, remote_sums_path, SUMS_FILENAME, args.require_all_hosts)
 479      if got_sums_status != ReturnCode.SUCCESS:
 480          return got_sums_status
 481  
 482      # Verify the signature on the SHA256SUMS file
 483      sigs_status, good_trusted, good_untrusted, unknown, bad = verify_shasums_signature(SIGNATUREFILENAME, SUMS_FILENAME, args)
 484      if sigs_status != ReturnCode.SUCCESS:
 485          if sigs_status == ReturnCode.INTEGRITY_FAILURE:
 486              cleanup()
 487          return sigs_status
 488  
 489      # Extract hashes and filenames
 490      hashes_to_verify = parse_sums_file(SUMS_FILENAME, [os_filter])
 491      if not hashes_to_verify:
 492          available_versions = ["-".join(line[1].split("-")[2:]) for line in parse_sums_file(SUMS_FILENAME, [])]
 493          closest_match = difflib.get_close_matches(os_filter, available_versions, cutoff=0, n=1)[0]
 494          log.error(f"No files matched the platform specified. Did you mean: {closest_match}")
 495          return ReturnCode.NO_BINARIES_MATCH
 496  
 497      # remove binaries that are known not to be hosted by bitcoincore.org
 498      fragments_to_remove = ['-unsigned', '-debug', '-codesignatures']
 499      for fragment in fragments_to_remove:
 500          nobinaries = [i for i in hashes_to_verify if fragment in i[1]]
 501          if nobinaries:
 502              remove_str = ', '.join(i[1] for i in nobinaries)
 503              log.info(
 504                  f"removing *{fragment} binaries ({remove_str}) from verification "
 505                  f"since {HOST1} does not host *{fragment} binaries")
 506              hashes_to_verify = [i for i in hashes_to_verify if fragment not in i[1]]
 507  
 508      # download binaries
 509      for _, binary_filename in hashes_to_verify:
 510          log.info(f"downloading {binary_filename} to {WORKINGDIR}")
 511          success, output = download_with_wget(
 512              HOST1 + remote_dir + binary_filename, binary_filename)
 513  
 514          if not success:
 515              log.error(
 516                  f"failed to download {binary_filename}\n"
 517                  f"wget output:\n{indent(output)}")
 518              return ReturnCode.BINARY_DOWNLOAD_FAILED
 519  
 520      # verify hashes
 521      hashes_status, files_to_hashes = verify_binary_hashes(hashes_to_verify)
 522      if hashes_status != ReturnCode.SUCCESS:
 523          return hashes_status
 524  
 525  
 526      if args.cleanup:
 527          cleanup()
 528      else:
 529          log.info(f"did not clean up {WORKINGDIR}")
 530  
 531      if args.json:
 532          output = {
 533              'good_trusted_sigs': [str(s) for s in good_trusted],
 534              'good_untrusted_sigs': [str(s) for s in good_untrusted],
 535              'unknown_sigs': [str(s) for s in unknown],
 536              'bad_sigs': [str(s) for s in bad],
 537              'verified_binaries': files_to_hashes,
 538          }
 539          print(json.dumps(output, indent=2))
 540      else:
 541          for filename in files_to_hashes:
 542              print(f"VERIFIED: {filename}")
 543  
 544      return ReturnCode.SUCCESS
 545  
 546  
 547  def verify_binaries_handler(args: argparse.Namespace) -> ReturnCode:
 548      binary_to_basename = {}
 549      for file in args.binary:
 550          binary_to_basename[PurePath(file).name] = file
 551  
 552      sums_sig_path = None
 553      if args.sums_sig_file:
 554          sums_sig_path = Path(args.sums_sig_file)
 555      else:
 556          log.info(f"No signature file specified, assuming it is {args.sums_file}.asc")
 557          sums_sig_path = Path(args.sums_file).with_suffix(".asc")
 558  
 559      # Verify the signature on the SHA256SUMS file
 560      sigs_status, good_trusted, good_untrusted, unknown, bad = verify_shasums_signature(str(sums_sig_path), args.sums_file, args)
 561      if sigs_status != ReturnCode.SUCCESS:
 562          return sigs_status
 563  
 564      # Extract hashes and filenames
 565      hashes_to_verify = parse_sums_file(args.sums_file, [k for k, n in binary_to_basename.items()])
 566      if not hashes_to_verify:
 567          log.error(f"No files in {args.sums_file} match the specified binaries")
 568          return ReturnCode.NO_BINARIES_MATCH
 569  
 570      # Make sure all files are accounted for
 571      sums_file_path = Path(args.sums_file)
 572      missing_files = []
 573      files_to_hash = []
 574      if len(binary_to_basename) > 0:
 575          for file_hash, file in hashes_to_verify:
 576              files_to_hash.append([file_hash, binary_to_basename[file]])
 577              del binary_to_basename[file]
 578          if len(binary_to_basename) > 0:
 579              log.error(f"Not all specified binaries are in {args.sums_file}")
 580              return ReturnCode.NO_BINARIES_MATCH
 581      else:
 582          log.info(f"No binaries specified, assuming all files specified in {args.sums_file} are located relatively")
 583          for file_hash, file in hashes_to_verify:
 584              file_path = Path(sums_file_path.parent.joinpath(file))
 585              if file_path.exists():
 586                  files_to_hash.append([file_hash, str(file_path)])
 587              else:
 588                  missing_files.append(file)
 589  
 590      # verify hashes
 591      hashes_status, files_to_hashes = verify_binary_hashes(files_to_hash)
 592      if hashes_status != ReturnCode.SUCCESS:
 593          return hashes_status
 594  
 595      if args.json:
 596          output = {
 597              'good_trusted_sigs': [str(s) for s in good_trusted],
 598              'good_untrusted_sigs': [str(s) for s in good_untrusted],
 599              'unknown_sigs': [str(s) for s in unknown],
 600              'bad_sigs': [str(s) for s in bad],
 601              'verified_binaries': files_to_hashes,
 602              "missing_binaries": missing_files,
 603          }
 604          print(json.dumps(output, indent=2))
 605      else:
 606          for filename in files_to_hashes:
 607              print(f"VERIFIED: {filename}")
 608          for filename in missing_files:
 609              print(f"MISSING: {filename}")
 610  
 611      return ReturnCode.SUCCESS
 612  
 613  
 614  def main():
 615      parser = argparse.ArgumentParser(description=__doc__)
 616      parser.add_argument(
 617          '-v', '--verbose', action='store_true',
 618          default=bool_from_env('BINVERIFY_VERBOSE'),
 619      )
 620      parser.add_argument(
 621          '-q', '--quiet', action='store_true',
 622          default=bool_from_env('BINVERIFY_QUIET'),
 623      )
 624      parser.add_argument(
 625          '--import-keys', action='store_true',
 626          default=bool_from_env('BINVERIFY_IMPORTKEYS'),
 627          help='if specified, ask to import each unknown builder key'
 628      )
 629      parser.add_argument(
 630          '--min-good-sigs', type=int, action='store', nargs='?',
 631          default=int(os.environ.get('BINVERIFY_MIN_GOOD_SIGS', 3)),
 632          help=(
 633              'The minimum number of good signatures to require successful termination.'),
 634      )
 635      parser.add_argument(
 636          '--keyserver', action='store', nargs='?',
 637          default=os.environ.get('BINVERIFY_KEYSERVER', 'hkps://keys.openpgp.org'),
 638          help='which keyserver to use',
 639      )
 640      parser.add_argument(
 641          '--trusted-keys', action='store', nargs='?',
 642          default=os.environ.get('BINVERIFY_TRUSTED_KEYS', ''),
 643          help='A list of trusted signer GPG keys, separated by commas. Not "trusted keys" in the GPG sense.',
 644      )
 645      parser.add_argument(
 646          '--json', action='store_true',
 647          default=bool_from_env('BINVERIFY_JSON'),
 648          help='If set, output the result as JSON',
 649      )
 650  
 651      subparsers = parser.add_subparsers(title="Commands", required=True, dest="command")
 652  
 653      pub_parser = subparsers.add_parser("pub", help="Verify a published release.")
 654      pub_parser.set_defaults(func=verify_published_handler)
 655      pub_parser.add_argument(
 656          'version', type=str, help=(
 657              f'version of the bitcoin release to download; of the format '
 658              f'{VERSION_FORMAT}. Example: {VERSION_EXAMPLE}')
 659      )
 660      pub_parser.add_argument(
 661          '--cleanup', action='store_true',
 662          default=bool_from_env('BINVERIFY_CLEANUP'),
 663          help='if specified, clean up files afterwards'
 664      )
 665      pub_parser.add_argument(
 666          '--require-all-hosts', action='store_true',
 667          default=bool_from_env('BINVERIFY_REQUIRE_ALL_HOSTS'),
 668          help=(
 669              f'If set, require all hosts ({HOST1}, {HOST2}) to provide signatures. '
 670              '(Sometimes bitcoin.org lags behind bitcoincore.org.)')
 671      )
 672  
 673      bin_parser = subparsers.add_parser("bin", help="Verify local binaries.")
 674      bin_parser.set_defaults(func=verify_binaries_handler)
 675      bin_parser.add_argument("--sums-sig-file", "-s", help="Path to the SHA256SUMS.asc file to verify")
 676      bin_parser.add_argument("sums_file", help="Path to the SHA256SUMS file to verify")
 677      bin_parser.add_argument(
 678          "binary", nargs="*",
 679          help="Path to a binary distribution file to verify. Can be specified multiple times for multiple files to verify."
 680      )
 681  
 682      args = parser.parse_args()
 683      if args.quiet:
 684          log.setLevel(logging.WARNING)
 685  
 686      return args.func(args)
 687  
 688  
 689  if __name__ == '__main__':
 690      sys.exit(main())
 691