@@ -98,60 +98,6 @@ def bool_from_env(key, default=False) -> bool:
9898VERSION_FORMAT = "<major>.<minor>[.<patch>][-rc[0-9]][-platform]"
9999VERSION_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-
155101def 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+
612647if __name__ == '__main__' :
613- sys .exit (main (sys . argv [ 1 :] ))
648+ sys .exit (main ())
0 commit comments