Skip to content

Commit 17575c0

Browse files
achow101theuni
authored andcommitted
contrib: Refactor verifbinaries to support subcommands
Prepares for the option to provide local binaries, sha256sums, and signatures directly.
1 parent 37c9fb7 commit 17575c0

File tree

2 files changed

+176
-138
lines changed

2 files changed

+176
-138
lines changed

contrib/verifybinaries/test.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88

99
def main():
1010
"""Tests ordered roughly from faster to slower."""
11-
expect_code(run_verify('0.32'), 4, "Nonexistent version should fail")
12-
expect_code(run_verify('0.32.awefa.12f9h'), 11, "Malformed version should fail")
13-
expect_code(run_verify('22.0 --min-good-sigs 20'), 9, "--min-good-sigs 20 should fail")
11+
expect_code(run_verify("", "pub", '0.32'), 4, "Nonexistent version should fail")
12+
expect_code(run_verify("", "pub", '0.32.awefa.12f9h'), 11, "Malformed version should fail")
13+
expect_code(run_verify('--min-good-sigs 20', "pub", "22.0"), 9, "--min-good-sigs 20 should fail")
1414

1515
print("- testing multisig verification (22.0)", flush=True)
16-
_220 = run_verify('22.0 --json')
16+
_220 = run_verify("--json", "pub", "22.0")
1717
try:
1818
result = json.loads(_220.stdout.decode())
1919
except Exception:
@@ -29,12 +29,15 @@ def main():
2929
assert v['bitcoin-22.0-x86_64-linux-gnu.tar.gz'] == '59ebd25dd82a51638b7a6bb914586201e67db67b919b2a1ff08925a7936d1b16'
3030

3131

32-
def run_verify(extra: str) -> subprocess.CompletedProcess:
32+
def run_verify(global_args: str, command: str, command_args: str) -> subprocess.CompletedProcess:
3333
maybe_here = Path.cwd() / 'verify.py'
3434
path = maybe_here if maybe_here.exists() else Path.cwd() / 'contrib' / 'verifybinaries' / 'verify.py'
3535

36+
if command == "pub":
37+
command += " --cleanup"
38+
3639
return subprocess.run(
37-
f"{path} --cleanup {extra}",
40+
f"{path} {global_args} {command} {command_args}",
3841
stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
3942

4043

contrib/verifybinaries/verify.py

Lines changed: 167 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -98,60 +98,6 @@ def bool_from_env(key, default=False) -> bool:
9898
VERSION_FORMAT = "<major>.<minor>[.<patch>][-rc[0-9]][-platform]"
9999
VERSION_EXAMPLE = "22.0-x86_64 or 0.21.0-rc2-osx"
100100

101-
parser = argparse.ArgumentParser(description=__doc__)
102-
parser.add_argument(
103-
'version', type=str, help=(
104-
f'version of the bitcoin release to download; of the format '
105-
f'{VERSION_FORMAT}. Example: {VERSION_EXAMPLE}')
106-
)
107-
parser.add_argument(
108-
'-v', '--verbose', action='store_true',
109-
default=bool_from_env('BINVERIFY_VERBOSE'),
110-
)
111-
parser.add_argument(
112-
'-q', '--quiet', action='store_true',
113-
default=bool_from_env('BINVERIFY_QUIET'),
114-
)
115-
parser.add_argument(
116-
'--cleanup', action='store_true',
117-
default=bool_from_env('BINVERIFY_CLEANUP'),
118-
help='if specified, clean up files afterwards'
119-
)
120-
parser.add_argument(
121-
'--import-keys', action='store_true',
122-
default=bool_from_env('BINVERIFY_IMPORTKEYS'),
123-
help='if specified, ask to import each unknown builder key'
124-
)
125-
parser.add_argument(
126-
'--require-all-hosts', action='store_true',
127-
default=bool_from_env('BINVERIFY_REQUIRE_ALL_HOSTS'),
128-
help=(
129-
f'If set, require all hosts ({HOST1}, {HOST2}) to provide signatures. '
130-
'(Sometimes bitcoin.org lags behind bitcoincore.org.)')
131-
)
132-
parser.add_argument(
133-
'--min-good-sigs', type=int, action='store', nargs='?',
134-
default=int(os.environ.get('BINVERIFY_MIN_GOOD_SIGS', 3)),
135-
help=(
136-
'The minimum number of good signatures to require successful termination.'),
137-
)
138-
parser.add_argument(
139-
'--keyserver', action='store', nargs='?',
140-
default=os.environ.get('BINVERIFY_KEYSERVER', 'hkp://keyserver.ubuntu.com'),
141-
help='which keyserver to use',
142-
)
143-
parser.add_argument(
144-
'--trusted-keys', action='store', nargs='?',
145-
default=os.environ.get('BINVERIFY_TRUSTED_KEYS', ''),
146-
help='A list of trusted signer GPG keys, separated by commas. Not "trusted keys" in the GPG sense.',
147-
)
148-
parser.add_argument(
149-
'--json', action='store_true',
150-
default=bool_from_env('BINVERIFY_JSON'),
151-
help='If set, output the result as JSON',
152-
)
153-
154-
155101
def parse_version_string(version_str):
156102
if version_str.startswith(VERSIONPREFIX): # remove version prefix
157103
version_str = version_str[len(VERSIONPREFIX):]
@@ -386,7 +332,7 @@ def join_url(host: str) -> str:
386332
return ReturnCode.SUCCESS
387333

388334

389-
def check_multisig(sigfilename: str, args: argparse.Namespace):
335+
def check_multisig(sigfilename: Path, args: argparse.Namespace) -> t.Tuple[int, str, t.List[SigData], t.List[SigData], t.List[SigData]]:
390336
# check signature
391337
#
392338
# We don't write output to a file because this command will almost certainly
@@ -423,61 +369,15 @@ def prompt_yn(prompt) -> bool:
423369
got = input(prompt).lower()
424370
return got == 'y'
425371

372+
def verify_shasums_signature(
373+
signature_file_path: str, sums_file_path: str, args: argparse.Namespace
374+
) -> t.Tuple[
375+
ReturnCode, t.List[SigData], t.List[SigData], t.List[SigData], t.List[SigData]
376+
]:
377+
min_good_sigs = args.min_good_sigs
378+
gpg_allowed_codes = [0, 2] # 2 is returned when untrusted signatures are present.
426379

427-
def main(args):
428-
args = parser.parse_args()
429-
if args.quiet:
430-
log.setLevel(logging.WARNING)
431-
432-
WORKINGDIR = Path(tempfile.gettempdir()) / f"bitcoin_verify_binaries.{args.version}"
433-
434-
def cleanup():
435-
log.info("cleaning up files")
436-
os.chdir(Path.home())
437-
shutil.rmtree(WORKINGDIR)
438-
439-
# determine remote dir dependent on provided version string
440-
try:
441-
version_base, version_rc, os_filter = parse_version_string(args.version)
442-
version_tuple = [int(i) for i in version_base.split('.')]
443-
except Exception as e:
444-
log.debug(e)
445-
log.error(f"unable to parse version; expected format is {VERSION_FORMAT}")
446-
log.error(f" e.g. {VERSION_EXAMPLE}")
447-
return ReturnCode.BAD_VERSION
448-
449-
remote_dir = f"/bin/{VERSIONPREFIX}{version_base}/"
450-
if version_rc:
451-
remote_dir += f"test.{version_rc}/"
452-
remote_sigs_path = remote_dir + SIGNATUREFILENAME
453-
remote_sums_path = remote_dir + SUMS_FILENAME
454-
455-
# create working directory
456-
os.makedirs(WORKINGDIR, exist_ok=True)
457-
os.chdir(WORKINGDIR)
458-
459-
hosts = [HOST1, HOST2]
460-
461-
got_sig_status = get_files_from_hosts_and_compare(
462-
hosts, remote_sigs_path, SIGNATUREFILENAME, args.require_all_hosts)
463-
if got_sig_status != ReturnCode.SUCCESS:
464-
return got_sig_status
465-
466-
# Multi-sig verification is available after 22.0.
467-
if version_tuple[0] >= 22:
468-
min_good_sigs = args.min_good_sigs
469-
gpg_allowed_codes = [0, 2] # 2 is returned when untrusted signatures are present.
470-
471-
got_sums_status = get_files_from_hosts_and_compare(
472-
hosts, remote_sums_path, SUMS_FILENAME, args.require_all_hosts)
473-
if got_sums_status != ReturnCode.SUCCESS:
474-
return got_sums_status
475-
476-
gpg_retval, gpg_output, good, unknown, bad = check_multisig(SIGNATUREFILENAME, args)
477-
else:
478-
log.error("Version too old - single sig not supported. Use a previous "
479-
"version of this script from the repo.")
480-
return ReturnCode.BAD_VERSION
380+
gpg_retval, gpg_output, good, unknown, bad = check_multisig(signature_file_path, args)
481381

482382
if gpg_retval not in gpg_allowed_codes:
483383
if gpg_retval == 1:
@@ -490,8 +390,7 @@ def cleanup():
490390
log.critical(f"unexpected GPG exit code ({gpg_retval})")
491391

492392
log.error(f"gpg output:\n{indent(gpg_output)}")
493-
cleanup()
494-
return ReturnCode.INTEGRITY_FAILURE
393+
return (ReturnCode.INTEGRITY_FAILURE, [], [], [], [])
495394

496395
# Decide which keys we trust, though not "trust" in the GPG sense, but rather
497396
# which pubkeys convince us that this sums file is legitimate. In other words,
@@ -503,7 +402,7 @@ def cleanup():
503402

504403
# Tally signatures and make sure we have enough goods to fulfill
505404
# our threshold.
506-
good_trusted = {sig for sig in good if sig.trusted or sig.key in trusted_keys}
405+
good_trusted = [sig for sig in good if sig.trusted or sig.key in trusted_keys]
507406
good_untrusted = [sig for sig in good if sig not in good_trusted]
508407
num_trusted = len(good_trusted) + len(good_untrusted)
509408
log.info(f"got {num_trusted} good signatures")
@@ -520,7 +419,7 @@ def cleanup():
520419
"not enough trusted sigs to meet threshold "
521420
f"({num_trusted} vs. {min_good_sigs})")
522421

523-
return ReturnCode.NOT_ENOUGH_GOOD_SIGS
422+
return (ReturnCode.NOT_ENOUGH_GOOD_SIGS, [], [], [], [])
524423

525424
for sig in good_trusted:
526425
log.info(f"GOOD SIGNATURE: {sig}")
@@ -537,10 +436,93 @@ def cleanup():
537436
for sig in unknown:
538437
log.warning(f"UNKNOWN SIGNATURE: {sig}")
539438

439+
return (ReturnCode.SUCCESS, good_trusted, good_untrusted, unknown, bad)
440+
441+
442+
def parse_sums_file(sums_file_path: Path, filename_filter: str) -> t.List[t.List[str]]:
540443
# extract hashes/filenames of binaries to verify from hash file;
541444
# each line has the following format: "<hash> <binary_filename>"
542-
with open(SUMS_FILENAME, 'r', encoding='utf8') as hash_file:
543-
hashes_to_verify = [line.split()[:2] for line in hash_file if os_filter in line]
445+
with open(sums_file_path, 'r', encoding='utf8') as hash_file:
446+
return [line.split()[:2] for line in hash_file if filename_filter in line]
447+
448+
449+
def verify_binary_hashes(hashes_to_verify: t.List[t.List[str]]) -> t.Tuple[ReturnCode, t.Dict[str, str]]:
450+
offending_files = []
451+
files_to_hashes = {}
452+
453+
for hash_expected, binary_filename in hashes_to_verify:
454+
with open(binary_filename, 'rb') as binary_file:
455+
hash_calculated = sha256(binary_file.read()).hexdigest()
456+
if hash_calculated != hash_expected:
457+
offending_files.append(binary_filename)
458+
else:
459+
files_to_hashes[binary_filename] = hash_calculated
460+
461+
if offending_files:
462+
joined_files = '\n'.join(offending_files)
463+
log.critical(
464+
"Hashes don't match.\n"
465+
f"Offending files:\n{joined_files}")
466+
return (ReturnCode.INTEGRITY_FAILURE, files_to_hashes)
467+
468+
return (ReturnCode.SUCCESS, files_to_hashes)
469+
470+
471+
def verify_published_handler(args: argparse.Namespace) -> ReturnCode:
472+
WORKINGDIR = Path(tempfile.gettempdir()) / f"bitcoin_verify_binaries.{args.version}"
473+
474+
def cleanup():
475+
log.info("cleaning up files")
476+
os.chdir(Path.home())
477+
shutil.rmtree(WORKINGDIR)
478+
479+
# determine remote dir dependent on provided version string
480+
try:
481+
version_base, version_rc, os_filter = parse_version_string(args.version)
482+
version_tuple = [int(i) for i in version_base.split('.')]
483+
except Exception as e:
484+
log.debug(e)
485+
log.error(f"unable to parse version; expected format is {VERSION_FORMAT}")
486+
log.error(f" e.g. {VERSION_EXAMPLE}")
487+
return ReturnCode.BAD_VERSION
488+
489+
remote_dir = f"/bin/{VERSIONPREFIX}{version_base}/"
490+
if version_rc:
491+
remote_dir += f"test.{version_rc}/"
492+
remote_sigs_path = remote_dir + SIGNATUREFILENAME
493+
remote_sums_path = remote_dir + SUMS_FILENAME
494+
495+
# create working directory
496+
os.makedirs(WORKINGDIR, exist_ok=True)
497+
os.chdir(WORKINGDIR)
498+
499+
hosts = [HOST1, HOST2]
500+
501+
got_sig_status = get_files_from_hosts_and_compare(
502+
hosts, remote_sigs_path, SIGNATUREFILENAME, args.require_all_hosts)
503+
if got_sig_status != ReturnCode.SUCCESS:
504+
return got_sig_status
505+
506+
# Multi-sig verification is available after 22.0.
507+
if version_tuple[0] < 22:
508+
log.error("Version too old - single sig not supported. Use a previous "
509+
"version of this script from the repo.")
510+
return ReturnCode.BAD_VERSION
511+
512+
got_sums_status = get_files_from_hosts_and_compare(
513+
hosts, remote_sums_path, SUMS_FILENAME, args.require_all_hosts)
514+
if got_sums_status != ReturnCode.SUCCESS:
515+
return got_sums_status
516+
517+
# Verify the signature on the SHA256SUMS file
518+
sigs_status, good_trusted, good_untrusted, unknown, bad = verify_shasums_signature(SIGNATUREFILENAME, SUMS_FILENAME, args)
519+
if sigs_status != ReturnCode.SUCCESS:
520+
if sigs_status == ReturnCode.INTEGRITY_FAILURE:
521+
cleanup()
522+
return sigs_status
523+
524+
# Extract hashes and filenames
525+
hashes_to_verify = parse_sums_file(SUMS_FILENAME, os_filter)
544526
remove_files([SUMS_FILENAME])
545527
if not hashes_to_verify:
546528
log.error("no files matched the platform specified")
@@ -570,23 +552,10 @@ def cleanup():
570552
return ReturnCode.BINARY_DOWNLOAD_FAILED
571553

572554
# verify hashes
573-
offending_files = []
574-
files_to_hashes = {}
555+
hashes_status, files_to_hashes = verify_binary_hashes(hashes_to_verify)
556+
if hashes_status != ReturnCode.SUCCESS:
557+
return hashes_status
575558

576-
for hash_expected, binary_filename in hashes_to_verify:
577-
with open(binary_filename, 'rb') as binary_file:
578-
hash_calculated = sha256(binary_file.read()).hexdigest()
579-
if hash_calculated != hash_expected:
580-
offending_files.append(binary_filename)
581-
else:
582-
files_to_hashes[binary_filename] = hash_calculated
583-
584-
if offending_files:
585-
joined_files = '\n'.join(offending_files)
586-
log.critical(
587-
"Hashes don't match.\n"
588-
f"Offending files:\n{joined_files}")
589-
return ReturnCode.INTEGRITY_FAILURE
590559

591560
if args.cleanup:
592561
cleanup()
@@ -609,5 +578,71 @@ def cleanup():
609578
return ReturnCode.SUCCESS
610579

611580

581+
def main():
582+
parser = argparse.ArgumentParser(description=__doc__)
583+
parser.add_argument(
584+
'-v', '--verbose', action='store_true',
585+
default=bool_from_env('BINVERIFY_VERBOSE'),
586+
)
587+
parser.add_argument(
588+
'-q', '--quiet', action='store_true',
589+
default=bool_from_env('BINVERIFY_QUIET'),
590+
)
591+
parser.add_argument(
592+
'--import-keys', action='store_true',
593+
default=bool_from_env('BINVERIFY_IMPORTKEYS'),
594+
help='if specified, ask to import each unknown builder key'
595+
)
596+
parser.add_argument(
597+
'--min-good-sigs', type=int, action='store', nargs='?',
598+
default=int(os.environ.get('BINVERIFY_MIN_GOOD_SIGS', 3)),
599+
help=(
600+
'The minimum number of good signatures to require successful termination.'),
601+
)
602+
parser.add_argument(
603+
'--keyserver', action='store', nargs='?',
604+
default=os.environ.get('BINVERIFY_KEYSERVER', 'hkp://keyserver.ubuntu.com'),
605+
help='which keyserver to use',
606+
)
607+
parser.add_argument(
608+
'--trusted-keys', action='store', nargs='?',
609+
default=os.environ.get('BINVERIFY_TRUSTED_KEYS', ''),
610+
help='A list of trusted signer GPG keys, separated by commas. Not "trusted keys" in the GPG sense.',
611+
)
612+
parser.add_argument(
613+
'--json', action='store_true',
614+
default=bool_from_env('BINVERIFY_JSON'),
615+
help='If set, output the result as JSON',
616+
)
617+
618+
subparsers = parser.add_subparsers(title="Commands", required=True, dest="command")
619+
620+
pub_parser = subparsers.add_parser("pub", help="Verify a published release.")
621+
pub_parser.set_defaults(func=verify_published_handler)
622+
pub_parser.add_argument(
623+
'version', type=str, help=(
624+
f'version of the bitcoin release to download; of the format '
625+
f'{VERSION_FORMAT}. Example: {VERSION_EXAMPLE}')
626+
)
627+
pub_parser.add_argument(
628+
'--cleanup', action='store_true',
629+
default=bool_from_env('BINVERIFY_CLEANUP'),
630+
help='if specified, clean up files afterwards'
631+
)
632+
pub_parser.add_argument(
633+
'--require-all-hosts', action='store_true',
634+
default=bool_from_env('BINVERIFY_REQUIRE_ALL_HOSTS'),
635+
help=(
636+
f'If set, require all hosts ({HOST1}, {HOST2}) to provide signatures. '
637+
'(Sometimes bitcoin.org lags behind bitcoincore.org.)')
638+
)
639+
640+
args = parser.parse_args()
641+
if args.quiet:
642+
log.setLevel(logging.WARNING)
643+
644+
return args.func(args)
645+
646+
612647
if __name__ == '__main__':
613-
sys.exit(main(sys.argv[1:]))
648+
sys.exit(main())

0 commit comments

Comments
 (0)