24
24
25
25
from cryptography .hazmat .primitives .serialization import Encoding
26
26
from cryptography .x509 import load_pem_x509_certificate
27
+ from rich .console import Console
27
28
from rich .logging import RichHandler
29
+ from sigstore_protobuf_specs .dev .sigstore .bundle .v1 import (
30
+ Bundle as RawBundle ,
31
+ )
28
32
29
33
from sigstore import __version__ , dsse
30
34
from sigstore ._internal .fulcio .client import ExpiredCertificate
31
35
from sigstore ._internal .rekor import _hashedrekord_from_parts
36
+ from sigstore ._internal .rekor .client import RekorClient
32
37
from sigstore ._internal .trust import ClientTrustConfig
33
38
from sigstore ._utils import sha256_digest
34
39
from sigstore .errors import Error , VerificationError
35
40
from sigstore .hashes import Hashed
36
- from sigstore .models import Bundle
41
+ from sigstore .models import Bundle , InvalidBundle
37
42
from sigstore .oidc import (
38
43
DEFAULT_OAUTH_ISSUER_URL ,
39
44
ExpiredIdentity ,
47
52
policy ,
48
53
)
49
54
50
- logging .basicConfig (format = "%(message)s" , datefmt = "[%X]" , handlers = [RichHandler ()])
55
+ _console = Console (file = sys .stderr )
56
+ logging .basicConfig (
57
+ format = "%(message)s" , datefmt = "[%X]" , handlers = [RichHandler (console = _console )]
58
+ )
51
59
_logger = logging .getLogger (__name__ )
52
60
53
61
# NOTE: We configure the top package logger, rather than the root logger,
56
64
_package_logger .setLevel (os .environ .get ("SIGSTORE_LOGLEVEL" , "INFO" ).upper ())
57
65
58
66
59
- def _die (args : argparse .Namespace , message : str ) -> NoReturn :
67
+ def _fatal (message : str ) -> NoReturn :
68
+ """
69
+ Logs a fatal condition and exits.
70
+ """
71
+ _logger .fatal (message )
72
+ sys .exit (1 )
73
+
74
+
75
+ def _invalid_arguments (args : argparse .Namespace , message : str ) -> NoReturn :
60
76
"""
61
77
An `argparse` helper that fixes up the type hints on our use of
62
78
`ArgumentParser.error`.
@@ -405,12 +421,54 @@ def _parser() -> argparse.ArgumentParser:
405
421
)
406
422
_add_shared_oidc_options (get_identity_token )
407
423
424
+ # `sigstore plumbing`
425
+ plumbing = subcommands .add_parser (
426
+ "plumbing" ,
427
+ help = "developer-only plumbing operations" ,
428
+ formatter_class = argparse .ArgumentDefaultsHelpFormatter ,
429
+ parents = [parent_parser ],
430
+ )
431
+ plumbing_subcommands = plumbing .add_subparsers (
432
+ required = True ,
433
+ dest = "plumbing_subcommand" ,
434
+ metavar = "COMMAND" ,
435
+ help = "the operation to perform" ,
436
+ )
437
+
438
+ # `sigstore plumbing fix-bundle`
439
+ fix_bundle = plumbing_subcommands .add_parser (
440
+ "fix-bundle" ,
441
+ help = "fix (and optionally upgrade) older bundle formats" ,
442
+ formatter_class = argparse .ArgumentDefaultsHelpFormatter ,
443
+ parents = [parent_parser ],
444
+ )
445
+ fix_bundle .add_argument (
446
+ "--bundle" ,
447
+ metavar = "FILE" ,
448
+ type = Path ,
449
+ required = True ,
450
+ help = ("The bundle to fix and/or upgrade" ),
451
+ )
452
+ fix_bundle .add_argument (
453
+ "--upgrade-version" ,
454
+ action = "store_true" ,
455
+ help = "Upgrade the bundle to the latest bundle spec version" ,
456
+ )
457
+ fix_bundle .add_argument (
458
+ "--in-place" ,
459
+ action = "store_true" ,
460
+ help = "Overwrite the input bundle with its fix instead of emitting to stdout" ,
461
+ )
462
+
408
463
return parser
409
464
410
465
411
- def main () -> None :
466
+ def main (args : list [str ] | None = None ) -> None :
467
+ if not args :
468
+ args = sys .argv [1 :]
469
+
412
470
parser = _parser ()
413
- args = parser .parse_args ()
471
+ args = parser .parse_args (args )
414
472
415
473
# Configure logging upfront, so that we don't miss anything.
416
474
if args .verbose >= 1 :
@@ -437,10 +495,12 @@ def main() -> None:
437
495
if identity :
438
496
print (identity )
439
497
else :
440
- _die (args , "No identity token supplied or detected!" )
441
-
498
+ _invalid_arguments (args , "No identity token supplied or detected!" )
499
+ elif args .subcommand == "plumbing" :
500
+ if args .plumbing_subcommand == "fix-bundle" :
501
+ _fix_bundle (args )
442
502
else :
443
- _die (args , f"Unknown subcommand: { args .subcommand } " )
503
+ _invalid_arguments (args , f"Unknown subcommand: { args .subcommand } " )
444
504
except Error as e :
445
505
e .log_and_exit (_logger , args .verbose >= 1 )
446
506
@@ -453,34 +513,38 @@ def _sign(args: argparse.Namespace) -> None:
453
513
# `--no-default-files` has no effect on `--bundle`, but we forbid it because
454
514
# it indicates user confusion.
455
515
if args .no_default_files and has_bundle :
456
- _die (args , "--no-default-files may not be combined with --bundle." )
516
+ _invalid_arguments (
517
+ args , "--no-default-files may not be combined with --bundle."
518
+ )
457
519
458
520
# Fail if `--signature` or `--certificate` is specified *and* we have more
459
521
# than one input.
460
522
if (has_sig or has_crt or has_bundle ) and len (args .files ) > 1 :
461
- _die (
523
+ _invalid_arguments (
462
524
args ,
463
525
"Error: --signature, --certificate, and --bundle can't be used with "
464
526
"explicit outputs for multiple inputs." ,
465
527
)
466
528
467
529
if args .output_directory and (has_sig or has_crt or has_bundle ):
468
- _die (
530
+ _invalid_arguments (
469
531
args ,
470
532
"Error: --signature, --certificate, and --bundle can't be used with "
471
533
"an explicit output directory." ,
472
534
)
473
535
474
536
# Fail if either `--signature` or `--certificate` is specified, but not both.
475
537
if has_sig ^ has_crt :
476
- _die (args , "Error: --signature and --certificate must be used together." )
538
+ _invalid_arguments (
539
+ args , "Error: --signature and --certificate must be used together."
540
+ )
477
541
478
542
# Build up the map of inputs -> outputs ahead of any signing operations,
479
543
# so that we can fail early if overwriting without `--overwrite`.
480
544
output_map : dict [Path , dict [str , Path | None ]] = {}
481
545
for file in args .files :
482
546
if not file .is_file ():
483
- _die (args , f"Input must be a file: { file } " )
547
+ _invalid_arguments (args , f"Input must be a file: { file } " )
484
548
485
549
sig , cert , bundle = (
486
550
args .signature ,
@@ -490,7 +554,9 @@ def _sign(args: argparse.Namespace) -> None:
490
554
491
555
output_dir = args .output_directory if args .output_directory else file .parent
492
556
if output_dir .exists () and not output_dir .is_dir ():
493
- _die (args , f"Output directory exists and is not a directory: { output_dir } " )
557
+ _invalid_arguments (
558
+ args , f"Output directory exists and is not a directory: { output_dir } "
559
+ )
494
560
output_dir .mkdir (parents = True , exist_ok = True )
495
561
496
562
if not bundle and not args .no_default_files :
@@ -506,7 +572,7 @@ def _sign(args: argparse.Namespace) -> None:
506
572
extants .append (str (bundle ))
507
573
508
574
if extants :
509
- _die (
575
+ _invalid_arguments (
510
576
args ,
511
577
"Refusing to overwrite outputs without --overwrite: "
512
578
f"{ ', ' .join (extants )} " ,
@@ -543,7 +609,7 @@ def _sign(args: argparse.Namespace) -> None:
543
609
identity = _get_identity (args )
544
610
545
611
if not identity :
546
- _die (args , "No identity token supplied or detected!" )
612
+ _invalid_arguments (args , "No identity token supplied or detected!" )
547
613
548
614
with signing_ctx .signer (identity ) as signer :
549
615
for file , outputs in output_map .items ():
@@ -609,26 +675,30 @@ def _collect_verification_state(
609
675
# Fail if --certificate, --signature, or --bundle is specified and we
610
676
# have more than one input.
611
677
if (args .certificate or args .signature or args .bundle ) and len (args .files ) > 1 :
612
- _die (
678
+ _invalid_arguments (
613
679
args ,
614
680
"--certificate, --signature, or --bundle can only be used "
615
681
"with a single input file" ,
616
682
)
617
683
618
684
# Fail if `--certificate` or `--signature` is used with `--bundle`.
619
685
if args .bundle and (args .certificate or args .signature ):
620
- _die (args , "--bundle cannot be used with --certificate or --signature" )
686
+ _invalid_arguments (
687
+ args , "--bundle cannot be used with --certificate or --signature"
688
+ )
621
689
622
690
# Fail if `--certificate` or `--signature` is used with `--offline`.
623
691
if args .offline and (args .certificate or args .signature ):
624
- _die (args , "--offline cannot be used with --certificate or --signature" )
692
+ _invalid_arguments (
693
+ args , "--offline cannot be used with --certificate or --signature"
694
+ )
625
695
626
696
# The converse of `sign`: we build up an expected input map and check
627
697
# that we have everything so that we can fail early.
628
698
input_map = {}
629
699
for file in args .files :
630
700
if not file .is_file ():
631
- _die (args , f"Input must be a file: { file } " )
701
+ _invalid_arguments (args , f"Input must be a file: { file } " )
632
702
633
703
sig , cert , bundle = (
634
704
args .signature ,
@@ -656,7 +726,7 @@ def _collect_verification_state(
656
726
elif bundle .is_file () and legacy_default_bundle .is_file ():
657
727
# Don't allow the user to implicitly verify `{input}.sigstore.json` if
658
728
# `{input}.sigstore` is also present, since this implies user confusion.
659
- _die (
729
+ _invalid_arguments (
660
730
args ,
661
731
f"Conflicting inputs: { bundle } and { legacy_default_bundle } " ,
662
732
)
@@ -678,7 +748,7 @@ def _collect_verification_state(
678
748
input_map [file ] = {"bundle" : bundle }
679
749
680
750
if missing :
681
- _die (
751
+ _invalid_arguments (
682
752
args ,
683
753
f"Missing verification materials for { (file )} : { ', ' .join (missing )} " ,
684
754
)
@@ -719,7 +789,9 @@ def _collect_verification_state(
719
789
_hashedrekord_from_parts (cert , signature , hashed )
720
790
)
721
791
if log_entry is None :
722
- _die (args , f"No matching log entry for { file } 's verification materials" )
792
+ _invalid_arguments (
793
+ args , f"No matching log entry for { file } 's verification materials"
794
+ )
723
795
bundle = Bundle .from_parts (cert , signature , log_entry )
724
796
725
797
_logger .debug (f"Verifying contents from: { file } " )
@@ -752,7 +824,7 @@ def _verify_github(args: argparse.Namespace) -> None:
752
824
# We require at least one of `--cert-identity` or `--repository`,
753
825
# to minimize the risk of user confusion about what's being verified.
754
826
if not (args .cert_identity or args .workflow_repository ):
755
- _die (args , "--cert-identity or --repository is required" )
827
+ _invalid_arguments (args , "--cert-identity or --repository is required" )
756
828
757
829
# No matter what the user configures above, we require the OIDC issuer to
758
830
# be GitHub Actions.
@@ -852,3 +924,44 @@ def _get_identity(args: argparse.Namespace) -> Optional[IdentityToken]:
852
924
)
853
925
854
926
return token
927
+
928
+
929
+ def _fix_bundle (args : argparse .Namespace ) -> None :
930
+ # NOTE: We could support `--trusted-root` here in the future,
931
+ # for custom Rekor instances.
932
+ if args .staging :
933
+ rekor = RekorClient .staging ()
934
+ else :
935
+ rekor = RekorClient .production ()
936
+
937
+ raw_bundle = RawBundle ().from_json (args .bundle .read_text ())
938
+
939
+ if len (raw_bundle .verification_material .tlog_entries ) != 1 :
940
+ _fatal ("unfixable bundle: must have exactly one log entry" )
941
+
942
+ # Some old versions of sigstore-python (1.x) produce malformed
943
+ # bundles where the inclusion proof is present but without
944
+ # its checkpoint. We fix these by retrieving the complete entry
945
+ # from Rekor and replacing the incomplete entry.
946
+ tlog_entry = raw_bundle .verification_material .tlog_entries [0 ]
947
+ inclusion_proof = tlog_entry .inclusion_proof
948
+ if not inclusion_proof .checkpoint :
949
+ _logger .info ("fixable: bundle's log entry is missing a checkpoint" )
950
+ new_entry = rekor .log .entries .get (log_index = tlog_entry .log_index )._to_rekor ()
951
+ raw_bundle .verification_material .tlog_entries = [new_entry ]
952
+
953
+ # Try to create our invariant-preserving Bundle from the any changes above.
954
+ try :
955
+ bundle = Bundle (raw_bundle )
956
+ except InvalidBundle as e :
957
+ e .log_and_exit (_logger )
958
+
959
+ # Round-trip through the bundle's parts to induce a version upgrade,
960
+ # if requested.
961
+ if args .upgrade_version :
962
+ bundle = Bundle ._from_parts (* bundle ._to_parts ())
963
+
964
+ if args .in_place :
965
+ args .bundle .write_text (bundle .to_json ())
966
+ else :
967
+ print (bundle .to_json ())
0 commit comments