verify.py
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())