From 3d9dc5400aeb3b43ecc7bd61049ce9aea39efec7 Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Fri, 20 Jan 2023 11:47:08 -0500
Subject: [PATCH 01/13] Initial Sigstore bundle support

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 .gitignore       |   1 +
 pyproject.toml   |   1 +
 sigstore/_cli.py | 106 ++++++++++++++++++++++++++++++++++-------------
 sigstore/sign.py |  83 +++++++++++++++++++++++++++++++++++++
 4 files changed, 163 insertions(+), 28 deletions(-)

diff --git a/.gitignore b/.gitignore
index 4533ac602..cb7d2edaa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ build
 *.sh
 *.pub
 *.rekor
+*.sigstore
 
 # Don't ignore these files when we intend to include them
 !sigstore/_store/*.crt
diff --git a/pyproject.toml b/pyproject.toml
index e803b343b..f20c079c4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ dependencies = [
   "pyOpenSSL >= 23.0.0",
   "requests",
   "securesystemslib",
+  "sigstore-protobuf-specs ~= 0.1.0",
   "tuf >= 2.0.0",
 ]
 requires-python = ">=3.7"
diff --git a/sigstore/_cli.py b/sigstore/_cli.py
index 035f6617e..19dd04a81 100644
--- a/sigstore/_cli.py
+++ b/sigstore/_cli.py
@@ -159,7 +159,7 @@ def _add_shared_instance_options(group: argparse._ArgumentGroup) -> None:
     )
 
 
-def _add_shared_input_options(group: argparse._ArgumentGroup) -> None:
+def _add_shared_verify_input_options(group: argparse._ArgumentGroup) -> None:
     """
     Common input options, shared between all `sigstore verify` subcommands.
     """
@@ -185,6 +185,15 @@ def _add_shared_input_options(group: argparse._ArgumentGroup) -> None:
         default=os.getenv("SIGSTORE_REKOR_BUNDLE"),
         help="The offline Rekor bundle to verify with; not used with multiple inputs",
     )
+    group.add_argument(
+        "--bundle",
+        action="store_true",
+        default=_boolify_env("SIGSTORE_BUNDLE"),
+        help=(
+            "Verify from {input}.sigstore for each input; this option is experimental "
+            "and may change between releases until stabilized"
+        ),
+    )
     group.add_argument(
         "files",
         metavar="FILE",
@@ -340,6 +349,15 @@ def _parser() -> argparse.ArgumentParser:
             "multiple input files"
         ),
     )
+    output_options.add_argument(
+        "--bundle",
+        action="store_true",
+        default=_boolify_env("SIGSTORE_BUNDLE"),
+        help=(
+            "Emit a single {input}.sigstore file for each input; this option is experimental "
+            "and may change between releases until stabilized"
+        ),
+    )
     output_options.add_argument(
         "--overwrite",
         action="store_true",
@@ -387,7 +405,7 @@ def _parser() -> argparse.ArgumentParser:
         formatter_class=argparse.ArgumentDefaultsHelpFormatter,
     )
     input_options = verify_identity.add_argument_group("Verification inputs")
-    _add_shared_input_options(input_options)
+    _add_shared_verify_input_options(input_options)
 
     verification_options = verify_identity.add_argument_group("Verification options")
     _add_shared_verification_options(verification_options)
@@ -420,7 +438,7 @@ def _parser() -> argparse.ArgumentParser:
     )
 
     input_options = verify_github.add_argument_group("Verification inputs")
-    _add_shared_input_options(input_options)
+    _add_shared_verify_input_options(input_options)
 
     verification_options = verify_github.add_argument_group("Verification options")
     _add_shared_verification_options(verification_options)
@@ -556,16 +574,21 @@ def _sign(args: argparse.Namespace) -> None:
             "upcoming release of sigstore-python in favor of Sigstore-style bundles"
         )
 
-    # `--no-default-files` has no effect on `--{signature,certificate,rekor-bundle}`, but we
-    # forbid it because it indicates user confusion.
+    # `--no-default-files` has no effect on `--{signature,certificate,rekor-bundle,bundle}`,
+    # but we forbid it because it indicates user confusion.
     if args.no_default_files and (
-        args.signature or args.certificate or args.rekor_bundle
+        args.signature or args.certificate or args.rekor_bundle or args.bundle
     ):
         args._parser.error(
             "--no-default-files may not be combined with --signature, "
-            "--certificate, or --rekor-bundle",
+            "--certificate, --rekor-bundle, or --bundle",
         )
 
+    # Similarly forbid `--rekor-bundle` with `--bundle`, since it again indicates
+    # user confusion around outputs.
+    if args.rekor_bundle and args.bundle:
+        args._parser.error("--rekor-bundle may not be combined with --bundle")
+
     # Fail if `--signature` or `--certificate` is specified *and* we have more
     # than one input.
     if (args.signature or args.certificate or args.rekor_bundle) and len(
@@ -579,33 +602,51 @@ def _sign(args: argparse.Namespace) -> None:
     # Build up the map of inputs -> outputs ahead of any signing operations,
     # so that we can fail early if overwriting without `--overwrite`.
     output_map = {}
+    extants = []
     for file in args.files:
         if not file.is_file():
             args._parser.error(f"Input must be a file: {file}")
 
-        sig, cert, bundle = args.signature, args.certificate, args.rekor_bundle
-        if not sig and not cert and not bundle and not args.no_default_files:
-            sig = file.parent / f"{file.name}.sig"
-            cert = file.parent / f"{file.name}.crt"
-            bundle = file.parent / f"{file.name}.rekor"
+        if args.bundle:
+            logger.warning(
+                "--bundle support is experimental; the behavior of this flag may change "
+                "between releases until stabilized."
+            )
+
+            bundle = file.parent / f"{file.name}.sigstore"
 
-        if not args.overwrite:
-            extants = []
-            if sig and sig.exists():
-                extants.append(str(sig))
-            if cert and cert.exists():
-                extants.append(str(cert))
-            if bundle and bundle.exists():
+            if bundle.exists():
                 extants.append(str(bundle))
 
+            output_map[file] = {"bundle": bundle}
+        else:
+            sig, cert, rekor_bundle = (
+                args.signature,
+                args.certificate,
+                args.rekor_bundle,
+            )
+
+            if not sig and not cert and not rekor_bundle and not args.no_default_files:
+                sig = file.parent / f"{file.name}.sig"
+                cert = file.parent / f"{file.name}.crt"
+                rekor_bundle = file.parent / f"{file.name}.rekor"
+
+                if sig and sig.exists():
+                    extants.append(str(sig))
+                if cert and cert.exists():
+                    extants.append(str(cert))
+                if rekor_bundle and rekor_bundle.exists():
+                    extants.append(str(rekor_bundle))
+
+            output_map[file] = {"cert": cert, "sig": sig, "rekor_bundle": rekor_bundle}
+
+        if not args.overwrite and len(extants) > 0:
             if extants:
                 args._parser.error(
                     "Refusing to overwrite outputs without --overwrite: "
                     f"{', '.join(extants)}"
                 )
 
-        output_map[file] = {"cert": cert, "sig": sig, "bundle": bundle}
-
     # Select the signer to use.
     if args.staging:
         logger.debug("sign: staging instances requested")
@@ -655,25 +696,30 @@ def _sign(args: argparse.Namespace) -> None:
         print(f"Transparency log entry created at index: {result.log_entry.log_index}")
 
         sig_output: TextIO
-        if outputs["sig"]:
+        if "sig" in outputs:
             sig_output = outputs["sig"].open("w")
         else:
             sig_output = sys.stdout
 
         print(result.b64_signature, file=sig_output)
-        if outputs["sig"] is not None:
+        if "sig" in outputs:
             print(f"Signature written to {outputs['sig']}")
 
-        if outputs["cert"] is not None:
+        if "cert" in outputs:
             with outputs["cert"].open(mode="w") as io:
                 print(result.cert_pem, file=io)
             print(f"Certificate written to {outputs['cert']}")
 
-        if outputs["bundle"] is not None:
+        if "rekor_bundle" in outputs:
+            with outputs["rekor_bundle"].open(mode="w") as io:
+                rekor_bundle = RekorBundle.from_entry(result.log_entry)
+                print(rekor_bundle.json(by_alias=True), file=io)
+            print(f"Rekor bundle written to {outputs['rekor_bundle']}")
+
+        if "bundle" in outputs:
             with outputs["bundle"].open(mode="w") as io:
-                bundle = RekorBundle.from_entry(result.log_entry)
-                print(bundle.json(by_alias=True), file=io)
-            print(f"Rekor bundle written to {outputs['bundle']}")
+                print(result._to_bundle().to_json(), file=io)
+            print(f"Sigstore bundle written to {outputs['bundle']}")
 
 
 def _collect_verification_state(
@@ -687,6 +733,10 @@ def _collect_verification_state(
     purposes) and `materials` is the `VerificationMaterials` to verify with.
     """
 
+    # TODO: Allow --bundle during verification. Until then, error.
+    if args.bundle:
+        args._parser.error("--bundle is not supported during verification yet")
+
     # `--rekor-bundle` is a temporary option, pending stabilization of the
     # Sigstore bundle format.
     if args.rekor_bundle:
diff --git a/sigstore/sign.py b/sigstore/sign.py
index 587b81dcd..f00b96253 100644
--- a/sigstore/sign.py
+++ b/sigstore/sign.py
@@ -47,6 +47,24 @@
 from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
 from cryptography.x509.oid import NameOID
 from pydantic import BaseModel
+from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import (
+    Bundle,
+    VerificationMaterial,
+)
+from sigstore_protobuf_specs.dev.sigstore.common.v1 import (
+    HashAlgorithm,
+    HashOutput,
+    LogId,
+    MessageSignature,
+    X509Certificate,
+    X509CertificateChain,
+)
+from sigstore_protobuf_specs.dev.sigstore.rekor.v1 import (
+    InclusionPromise,
+    InclusionProof,
+    KindVersion,
+    TransparencyLogEntry,
+)
 
 from sigstore._internal.fulcio import FulcioClient
 from sigstore._internal.oidc import Identity
@@ -163,6 +181,7 @@ def sign(
         logger.debug(f"Transparency log entry created with index: {entry.log_index}")
 
         return SigningResult(
+            input_digest=input_digest.hex(),
             cert_pem=cert.public_bytes(encoding=serialization.Encoding.PEM).decode(),
             b64_signature=b64_artifact_signature,
             log_entry=entry,
@@ -174,6 +193,11 @@ class SigningResult(BaseModel):
     Represents the artifacts of a signing operation.
     """
 
+    input_digest: str
+    """
+    The hex-encoded SHA256 digest of the input that was signed for.
+    """
+
     cert_pem: str
     """
     The PEM-encoded public half of the certificate used for signing.
@@ -188,3 +212,62 @@ class SigningResult(BaseModel):
     """
     A record of the Rekor log entry for the signing operation.
     """
+
+    def _to_bundle(self) -> Bundle:
+        """
+        Creates a Sigstore bundle (as defined by Sigstore's protobuf specs)
+        from this `SigningResult`.
+        """
+
+        # TODO: Should we include our Fulcio intermediate in the chain?
+        chain = X509CertificateChain(
+            certificates=[X509Certificate(raw_bytes=self.cert_pem.encode())]
+        )
+
+        inclusion_proof: InclusionProof | None = None
+        if self.log_entry.inclusion_proof is not None:
+            inclusion_proof = InclusionProof(
+                log_index=self.log_entry.inclusion_proof.log_index,
+                root_hash=bytes.fromhex(self.log_entry.inclusion_proof.root_hash),
+                tree_size=self.log_entry.inclusion_proof.tree_size,
+                hashes=[
+                    bytes.fromhex(h) for h in self.log_entry.inclusion_proof.hashes
+                ],
+            )
+
+        tlog_entry = TransparencyLogEntry(
+            log_index=self.log_entry.log_index,
+            log_id=LogId(key_id=bytes.fromhex(self.log_entry.log_id)),
+            # TODO: Maybe leave this field out? It appears to be optional
+            # and it's just hardcoded for us, since it's the only kind of Rekor
+            # entry we support.
+            kind_version=KindVersion(kind="hashedrekord", version="0.0.1"),
+            integrated_time=self.log_entry.integrated_time,
+            inclusion_promise=InclusionPromise(
+                signed_entry_timestamp=base64.b64decode(
+                    self.log_entry.signed_entry_timestamp
+                )
+            ),
+            inclusion_proof=inclusion_proof,
+            # TODO: Is this correct?
+            canonicalized_body=self.log_entry.encode_canonical(),
+        )
+
+        material = VerificationMaterial(
+            x509_certificate_chain=chain,
+            tlog_entries=[tlog_entry],
+        )
+
+        bundle = Bundle(
+            media_type="application/vnd.dev.sigstore.bundle+json;version=0.1",
+            verification_material=material,
+            message_signature=MessageSignature(
+                message_digest=HashOutput(
+                    algorithm=HashAlgorithm.SHA2_256,
+                    digest=bytes.fromhex(self.input_digest),
+                ),
+                signature=base64.b64decode(self.b64_signature),
+            ),
+        )
+
+        return bundle

From a0377a4f4bfc04c96e49ed1950826b27cfdaca5e Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Fri, 20 Jan 2023 11:53:30 -0500
Subject: [PATCH 02/13] README: update `--help` texts

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 README.md | 29 ++++++++++++++++++++---------
 1 file changed, 20 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index 9ec3e6a4f..dcad5f763 100644
--- a/README.md
+++ b/README.md
@@ -131,9 +131,10 @@ usage: sigstore sign [-h] [--identity-token TOKEN] [--oidc-client-id ID]
                      [--oidc-client-secret SECRET]
                      [--oidc-disable-ambient-providers] [--oidc-issuer URL]
                      [--no-default-files] [--signature FILE]
-                     [--certificate FILE] [--rekor-bundle FILE] [--overwrite]
-                     [--staging] [--rekor-url URL] [--rekor-root-pubkey FILE]
-                     [--fulcio-url URL] [--ctfe FILE]
+                     [--certificate FILE] [--rekor-bundle FILE] [--bundle]
+                     [--overwrite] [--staging] [--rekor-url URL]
+                     [--rekor-root-pubkey FILE] [--fulcio-url URL]
+                     [--ctfe FILE]
                      FILE [FILE ...]
 
 positional arguments:
@@ -169,6 +170,9 @@ Output options:
                         Write a single offline Rekor bundle to the given file;
                         does not work with multiple input files (default:
                         None)
+  --bundle              Emit a single {input}.sigstore file for each input;
+                        this option is experimental and may change between
+                        releases until stabilized (default: False)
   --overwrite           Overwrite preexisting signature and certificate
                         outputs, if present (default: False)
 
@@ -205,7 +209,8 @@ to by a particular OIDC provider (like `https://github.com/login/oauth`).
 <!-- @begin-sigstore-verify-identity-help@ -->
 ```
 usage: sigstore verify identity [-h] [--certificate FILE] [--signature FILE]
-                                [--rekor-bundle FILE] --cert-identity IDENTITY
+                                [--rekor-bundle FILE] [--bundle]
+                                --cert-identity IDENTITY
                                 [--require-rekor-offline] --cert-oidc-issuer
                                 URL [--staging] [--rekor-url URL]
                                 [--rekor-root-pubkey FILE]
@@ -223,6 +228,9 @@ Verification inputs:
                         multiple inputs (default: None)
   --rekor-bundle FILE   The offline Rekor bundle to verify with; not used with
                         multiple inputs (default: None)
+  --bundle              Verify from {input}.sigstore for each input; this
+                        option is experimental and may change between releases
+                        until stabilized (default: False)
   FILE                  The file to verify
 
 Verification options:
@@ -271,11 +279,11 @@ claims more precisely than `sigstore verify identity` allows:
 <!-- @begin-sigstore-verify-github-help@ -->
 ```
 usage: sigstore verify github [-h] [--certificate FILE] [--signature FILE]
-                              [--rekor-bundle FILE] --cert-identity IDENTITY
-                              [--require-rekor-offline] [--trigger EVENT]
-                              [--sha SHA] [--name NAME] [--repository REPO]
-                              [--ref REF] [--staging] [--rekor-url URL]
-                              [--rekor-root-pubkey FILE]
+                              [--rekor-bundle FILE] [--bundle] --cert-identity
+                              IDENTITY [--require-rekor-offline]
+                              [--trigger EVENT] [--sha SHA] [--name NAME]
+                              [--repository REPO] [--ref REF] [--staging]
+                              [--rekor-url URL] [--rekor-root-pubkey FILE]
                               [--certificate-chain FILE]
                               FILE [FILE ...]
 
@@ -290,6 +298,9 @@ Verification inputs:
                         multiple inputs (default: None)
   --rekor-bundle FILE   The offline Rekor bundle to verify with; not used with
                         multiple inputs (default: None)
+  --bundle              Verify from {input}.sigstore for each input; this
+                        option is experimental and may change between releases
+                        until stabilized (default: False)
   FILE                  The file to verify
 
 Verification options:

From 63d2d8756655258d8ef6d607423f50e28f08ee33 Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Fri, 20 Jan 2023 13:14:00 -0500
Subject: [PATCH 03/13] sign: fix bundle generation

Certs are base64'd DER, not PEM, and the canonicalized_body
is the log entry body, not the canonicalized contents that
the SET is signed over.

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 sigstore/sign.py | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/sigstore/sign.py b/sigstore/sign.py
index f00b96253..48385e053 100644
--- a/sigstore/sign.py
+++ b/sigstore/sign.py
@@ -220,9 +220,9 @@ def _to_bundle(self) -> Bundle:
         """
 
         # TODO: Should we include our Fulcio intermediate in the chain?
-        chain = X509CertificateChain(
-            certificates=[X509Certificate(raw_bytes=self.cert_pem.encode())]
-        )
+        cert = x509.load_pem_x509_certificate(self.cert_pem.encode())
+        cert_der = cert.public_bytes(encoding=serialization.Encoding.DER)
+        chain = X509CertificateChain(certificates=[X509Certificate(raw_bytes=cert_der)])
 
         inclusion_proof: InclusionProof | None = None
         if self.log_entry.inclusion_proof is not None:
@@ -249,8 +249,7 @@ def _to_bundle(self) -> Bundle:
                 )
             ),
             inclusion_proof=inclusion_proof,
-            # TODO: Is this correct?
-            canonicalized_body=self.log_entry.encode_canonical(),
+            canonicalized_body=base64.b64decode(self.log_entry.body),
         )
 
         material = VerificationMaterial(

From 56f063697f0af3c87e8dbbea8acd3a062ac12480 Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Mon, 23 Jan 2023 21:25:25 -0600
Subject: [PATCH 04/13] sign: remove TODO

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 sigstore/sign.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/sigstore/sign.py b/sigstore/sign.py
index 48385e053..65ffde9de 100644
--- a/sigstore/sign.py
+++ b/sigstore/sign.py
@@ -238,9 +238,6 @@ def _to_bundle(self) -> Bundle:
         tlog_entry = TransparencyLogEntry(
             log_index=self.log_entry.log_index,
             log_id=LogId(key_id=bytes.fromhex(self.log_entry.log_id)),
-            # TODO: Maybe leave this field out? It appears to be optional
-            # and it's just hardcoded for us, since it's the only kind of Rekor
-            # entry we support.
             kind_version=KindVersion(kind="hashedrekord", version="0.0.1"),
             integrated_time=self.log_entry.integrated_time,
             inclusion_promise=InclusionPromise(

From 8651f955bfa2b60f50d73d8cbf2f63696ab73910 Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Mon, 23 Jan 2023 21:29:01 -0600
Subject: [PATCH 05/13] sign: update TODO

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 sigstore/sign.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/sigstore/sign.py b/sigstore/sign.py
index 65ffde9de..a948a847e 100644
--- a/sigstore/sign.py
+++ b/sigstore/sign.py
@@ -219,7 +219,8 @@ def _to_bundle(self) -> Bundle:
         from this `SigningResult`.
         """
 
-        # TODO: Should we include our Fulcio intermediate in the chain?
+        # TODO: Include the current Fulcio intermediate and root in the
+        # chain as well.
         cert = x509.load_pem_x509_certificate(self.cert_pem.encode())
         cert_der = cert.public_bytes(encoding=serialization.Encoding.DER)
         chain = X509CertificateChain(certificates=[X509Certificate(raw_bytes=cert_der)])

From cd7d31cb4a6cb3de711bccc64fa76eb124c5117a Mon Sep 17 00:00:00 2001
From: Alex Cameron <asc@tetsuo.sh>
Date: Thu, 26 Jan 2023 00:04:01 +1100
Subject: [PATCH 06/13] _cli: Make `--bundle` refer to a path and create a
 `--no-bundle` flag to control whether Sigstore bundles are emitted by default

Signed-off-by: Alex Cameron <asc@tetsuo.sh>
---
 sigstore/_cli.py | 117 +++++++++++++++++++++++++++++------------------
 1 file changed, 73 insertions(+), 44 deletions(-)

diff --git a/sigstore/_cli.py b/sigstore/_cli.py
index 19dd04a81..a0dd42c9b 100644
--- a/sigstore/_cli.py
+++ b/sigstore/_cli.py
@@ -187,11 +187,12 @@ def _add_shared_verify_input_options(group: argparse._ArgumentGroup) -> None:
     )
     group.add_argument(
         "--bundle",
-        action="store_true",
-        default=_boolify_env("SIGSTORE_BUNDLE"),
+        metavar="FILE",
+        type=Path,
+        default=os.getenv("SIGSTORE_BUNDLE"),
         help=(
-            "Verify from {input}.sigstore for each input; this option is experimental "
-            "and may change between releases until stabilized"
+            "The Sigstore bundle to verify with; not used with multiple inputs; this option is "
+            "experimental and may change between releases until stabilized"
         ),
     )
     group.add_argument(
@@ -351,10 +352,20 @@ def _parser() -> argparse.ArgumentParser:
     )
     output_options.add_argument(
         "--bundle",
+        metavar="FILE",
+        type=Path,
+        default=os.getenv("SIGSTORE_BUNDLE"),
+        help=(
+            "Write a single Sigstore bundle to the given file; does not work with multiple input "
+            "files; this option is experimental and may change between releases until stabilized"
+        ),
+    )
+    output_options.add_argument(
+        "--no-bundle",
         action="store_true",
-        default=_boolify_env("SIGSTORE_BUNDLE"),
+        default=False,
         help=(
-            "Emit a single {input}.sigstore file for each input; this option is experimental "
+            "Don't emit {input}.sigstore files for each input; this option is experimental "
             "and may change between releases until stabilized"
         ),
     )
@@ -574,6 +585,18 @@ def _sign(args: argparse.Namespace) -> None:
             "upcoming release of sigstore-python in favor of Sigstore-style bundles"
         )
 
+    if args.bundle:
+        logger.warning(
+            "--bundle support is experimental; this flag may change behaviour "
+            "between releases until stabilized or may be removed."
+        )
+
+    if args.no_bundle:
+        logger.warning(
+            "--no-bundle support is experimental; this flag may change behaviour "
+            "between releases until stabilized or may be removed."
+        )
+
     # `--no-default-files` has no effect on `--{signature,certificate,rekor-bundle,bundle}`,
     # but we forbid it because it indicates user confusion.
     if args.no_default_files and (
@@ -589,6 +612,10 @@ def _sign(args: argparse.Namespace) -> None:
     if args.rekor_bundle and args.bundle:
         args._parser.error("--rekor-bundle may not be combined with --bundle")
 
+    # Fail if `--bundle` and `--no-bundle` are both specified.
+    if args.bundle and args.no_bundle:
+        args._parser.error("--bundle may not be combined with --no-bundle")
+
     # Fail if `--signature` or `--certificate` is specified *and* we have more
     # than one input.
     if (args.signature or args.certificate or args.rekor_bundle) and len(
@@ -602,51 +629,53 @@ def _sign(args: argparse.Namespace) -> None:
     # Build up the map of inputs -> outputs ahead of any signing operations,
     # so that we can fail early if overwriting without `--overwrite`.
     output_map = {}
-    extants = []
     for file in args.files:
         if not file.is_file():
             args._parser.error(f"Input must be a file: {file}")
 
-        if args.bundle:
-            logger.warning(
-                "--bundle support is experimental; the behavior of this flag may change "
-                "between releases until stabilized."
-            )
-
-            bundle = file.parent / f"{file.name}.sigstore"
-
-            if bundle.exists():
+        sig, cert, rekor_bundle, bundle = (
+            args.signature,
+            args.certificate,
+            args.rekor_bundle,
+            args.bundle,
+        )
+        if (
+            not sig
+            and not cert
+            and not rekor_bundle
+            and not bundle
+            and not args.no_default_files
+        ):
+            sig = file.parent / f"{file.name}.sig"
+            cert = file.parent / f"{file.name}.crt"
+            rekor_bundle = file.parent / f"{file.name}.rekor"
+            if not args.no_bundle:
+                bundle = file.parent / f"{file.name}.sigstore"
+
+        extants = []
+        if not args.overwrite:
+            if sig and sig.exists():
+                extants.append(str(sig))
+            if cert and cert.exists():
+                extants.append(str(cert))
+            if rekor_bundle and rekor_bundle.exists():
+                extants.append(str(rekor_bundle))
+            if bundle and bundle.exists():
                 extants.append(str(bundle))
 
-            output_map[file] = {"bundle": bundle}
-        else:
-            sig, cert, rekor_bundle = (
-                args.signature,
-                args.certificate,
-                args.rekor_bundle,
-            )
-
-            if not sig and not cert and not rekor_bundle and not args.no_default_files:
-                sig = file.parent / f"{file.name}.sig"
-                cert = file.parent / f"{file.name}.crt"
-                rekor_bundle = file.parent / f"{file.name}.rekor"
-
-                if sig and sig.exists():
-                    extants.append(str(sig))
-                if cert and cert.exists():
-                    extants.append(str(cert))
-                if rekor_bundle and rekor_bundle.exists():
-                    extants.append(str(rekor_bundle))
-
-            output_map[file] = {"cert": cert, "sig": sig, "rekor_bundle": rekor_bundle}
-
-        if not args.overwrite and len(extants) > 0:
             if extants:
                 args._parser.error(
                     "Refusing to overwrite outputs without --overwrite: "
                     f"{', '.join(extants)}"
                 )
 
+        output_map[file] = {
+            "cert": cert,
+            "sig": sig,
+            "rekor_bundle": rekor_bundle,
+            "bundle": bundle,
+        }
+
     # Select the signer to use.
     if args.staging:
         logger.debug("sign: staging instances requested")
@@ -696,27 +725,27 @@ def _sign(args: argparse.Namespace) -> None:
         print(f"Transparency log entry created at index: {result.log_entry.log_index}")
 
         sig_output: TextIO
-        if "sig" in outputs:
+        if outputs["sig"] in outputs:
             sig_output = outputs["sig"].open("w")
         else:
             sig_output = sys.stdout
 
         print(result.b64_signature, file=sig_output)
-        if "sig" in outputs:
+        if outputs["sig"] is not None:
             print(f"Signature written to {outputs['sig']}")
 
-        if "cert" in outputs:
+        if outputs["cert"] is not None:
             with outputs["cert"].open(mode="w") as io:
                 print(result.cert_pem, file=io)
             print(f"Certificate written to {outputs['cert']}")
 
-        if "rekor_bundle" in outputs:
+        if outputs["rekor_bundle"] is not None:
             with outputs["rekor_bundle"].open(mode="w") as io:
                 rekor_bundle = RekorBundle.from_entry(result.log_entry)
                 print(rekor_bundle.json(by_alias=True), file=io)
             print(f"Rekor bundle written to {outputs['rekor_bundle']}")
 
-        if "bundle" in outputs:
+        if outputs["bundle"] is not None:
             with outputs["bundle"].open(mode="w") as io:
                 print(result._to_bundle().to_json(), file=io)
             print(f"Sigstore bundle written to {outputs['bundle']}")

From a25e546ca72a99b3c5f36ff3e146e08b65afcb91 Mon Sep 17 00:00:00 2001
From: Alex Cameron <asc@tetsuo.sh>
Date: Thu, 26 Jan 2023 00:07:15 +1100
Subject: [PATCH 07/13] _cli: Move variable to correct scope

Signed-off-by: Alex Cameron <asc@tetsuo.sh>
---
 sigstore/_cli.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sigstore/_cli.py b/sigstore/_cli.py
index a0dd42c9b..94eb1e45f 100644
--- a/sigstore/_cli.py
+++ b/sigstore/_cli.py
@@ -652,8 +652,8 @@ def _sign(args: argparse.Namespace) -> None:
             if not args.no_bundle:
                 bundle = file.parent / f"{file.name}.sigstore"
 
-        extants = []
         if not args.overwrite:
+            extants = []
             if sig and sig.exists():
                 extants.append(str(sig))
             if cert and cert.exists():

From 93698cd0f76a7c52dd5114cda5ea05a1fe521222 Mon Sep 17 00:00:00 2001
From: Alex Cameron <asc@tetsuo.sh>
Date: Thu, 26 Jan 2023 00:09:48 +1100
Subject: [PATCH 08/13] _cli: Reword warnings for bundle flags

Signed-off-by: Alex Cameron <asc@tetsuo.sh>
---
 sigstore/_cli.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/sigstore/_cli.py b/sigstore/_cli.py
index 94eb1e45f..bf4f924f8 100644
--- a/sigstore/_cli.py
+++ b/sigstore/_cli.py
@@ -587,14 +587,14 @@ def _sign(args: argparse.Namespace) -> None:
 
     if args.bundle:
         logger.warning(
-            "--bundle support is experimental; this flag may change behaviour "
-            "between releases until stabilized or may be removed."
+            "--bundle support is experimental; the behaviour of this flag may change "
+            "between releases until stabilized."
         )
 
     if args.no_bundle:
         logger.warning(
-            "--no-bundle support is experimental; this flag may change behaviour "
-            "between releases until stabilized or may be removed."
+            "--no-bundle support is experimental; the behaviour of this flag may change "
+            "between releases until stabilized."
         )
 
     # `--no-default-files` has no effect on `--{signature,certificate,rekor-bundle,bundle}`,

From ae7cc838331c981fda8c8e3d95511490da33c813 Mon Sep 17 00:00:00 2001
From: Alex Cameron <asc@tetsuo.sh>
Date: Thu, 26 Jan 2023 00:13:14 +1100
Subject: [PATCH 09/13] README: Fix sign example

Signed-off-by: Alex Cameron <asc@tetsuo.sh>
---
 README.md | 18 +++++++++++-------
 1 file changed, 11 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index dcad5f763..d7abb2ed5 100644
--- a/README.md
+++ b/README.md
@@ -131,10 +131,10 @@ usage: sigstore sign [-h] [--identity-token TOKEN] [--oidc-client-id ID]
                      [--oidc-client-secret SECRET]
                      [--oidc-disable-ambient-providers] [--oidc-issuer URL]
                      [--no-default-files] [--signature FILE]
-                     [--certificate FILE] [--rekor-bundle FILE] [--bundle]
-                     [--overwrite] [--staging] [--rekor-url URL]
-                     [--rekor-root-pubkey FILE] [--fulcio-url URL]
-                     [--ctfe FILE]
+                     [--certificate FILE] [--rekor-bundle FILE]
+                     [--bundle FILE] [--no-bundle] [--overwrite] [--staging]
+                     [--rekor-url URL] [--rekor-root-pubkey FILE]
+                     [--fulcio-url URL] [--ctfe FILE]
                      FILE [FILE ...]
 
 positional arguments:
@@ -170,9 +170,13 @@ Output options:
                         Write a single offline Rekor bundle to the given file;
                         does not work with multiple input files (default:
                         None)
-  --bundle              Emit a single {input}.sigstore file for each input;
-                        this option is experimental and may change between
-                        releases until stabilized (default: False)
+  --bundle FILE         Write a single Sigstore bundle to the given file; does
+                        not work with multiple input files; this option is
+                        experimental and may change between releases until
+                        stabilized (default: None)
+  --no-bundle           Don't emit {input}.sigstore files for each input; this
+                        option is experimental and may change between releases
+                        until stabilized (default: False)
   --overwrite           Overwrite preexisting signature and certificate
                         outputs, if present (default: False)
 

From be7bf1bc2cd08e10d84b6bae14ba0c4b61fbe654 Mon Sep 17 00:00:00 2001
From: Alex Cameron <asc@tetsuo.sh>
Date: Thu, 26 Jan 2023 00:16:25 +1100
Subject: [PATCH 10/13] README: Update verify invocations

Signed-off-by: Alex Cameron <asc@tetsuo.sh>
---
 README.md | 20 +++++++++++---------
 1 file changed, 11 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index d7abb2ed5..3eb039a06 100644
--- a/README.md
+++ b/README.md
@@ -213,7 +213,7 @@ to by a particular OIDC provider (like `https://github.com/login/oauth`).
 <!-- @begin-sigstore-verify-identity-help@ -->
 ```
 usage: sigstore verify identity [-h] [--certificate FILE] [--signature FILE]
-                                [--rekor-bundle FILE] [--bundle]
+                                [--rekor-bundle FILE] [--bundle FILE]
                                 --cert-identity IDENTITY
                                 [--require-rekor-offline] --cert-oidc-issuer
                                 URL [--staging] [--rekor-url URL]
@@ -232,9 +232,10 @@ Verification inputs:
                         multiple inputs (default: None)
   --rekor-bundle FILE   The offline Rekor bundle to verify with; not used with
                         multiple inputs (default: None)
-  --bundle              Verify from {input}.sigstore for each input; this
-                        option is experimental and may change between releases
-                        until stabilized (default: False)
+  --bundle FILE         The Sigstore bundle to verify with; not used with
+                        multiple inputs; this option is experimental and may
+                        change between releases until stabilized (default:
+                        None)
   FILE                  The file to verify
 
 Verification options:
@@ -283,8 +284,8 @@ claims more precisely than `sigstore verify identity` allows:
 <!-- @begin-sigstore-verify-github-help@ -->
 ```
 usage: sigstore verify github [-h] [--certificate FILE] [--signature FILE]
-                              [--rekor-bundle FILE] [--bundle] --cert-identity
-                              IDENTITY [--require-rekor-offline]
+                              [--rekor-bundle FILE] [--bundle FILE]
+                              --cert-identity IDENTITY [--require-rekor-offline]
                               [--trigger EVENT] [--sha SHA] [--name NAME]
                               [--repository REPO] [--ref REF] [--staging]
                               [--rekor-url URL] [--rekor-root-pubkey FILE]
@@ -302,9 +303,10 @@ Verification inputs:
                         multiple inputs (default: None)
   --rekor-bundle FILE   The offline Rekor bundle to verify with; not used with
                         multiple inputs (default: None)
-  --bundle              Verify from {input}.sigstore for each input; this
-                        option is experimental and may change between releases
-                        until stabilized (default: False)
+  --bundle FILE         The Sigstore bundle to verify with; not used with
+                        multiple inputs; this option is experimental and may
+                        change between releases until stabilized (default:
+                        None)
   FILE                  The file to verify
 
 Verification options:

From c2559dabfff981328872dccf045c6a3a3737a4fc Mon Sep 17 00:00:00 2001
From: Alex Cameron <asc@tetsuo.sh>
Date: Thu, 26 Jan 2023 00:18:00 +1100
Subject: [PATCH 11/13] README: Fix line breaks

Signed-off-by: Alex Cameron <asc@tetsuo.sh>
---
 README.md | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 3eb039a06..ce3392599 100644
--- a/README.md
+++ b/README.md
@@ -285,10 +285,11 @@ claims more precisely than `sigstore verify identity` allows:
 ```
 usage: sigstore verify github [-h] [--certificate FILE] [--signature FILE]
                               [--rekor-bundle FILE] [--bundle FILE]
-                              --cert-identity IDENTITY [--require-rekor-offline]
-                              [--trigger EVENT] [--sha SHA] [--name NAME]
-                              [--repository REPO] [--ref REF] [--staging]
-                              [--rekor-url URL] [--rekor-root-pubkey FILE]
+                              --cert-identity IDENTITY
+                              [--require-rekor-offline] [--trigger EVENT]
+                              [--sha SHA] [--name NAME] [--repository REPO]
+                              [--ref REF] [--staging] [--rekor-url URL]
+                              [--rekor-root-pubkey FILE]
                               [--certificate-chain FILE]
                               FILE [FILE ...]
 

From d045d21ebd0e714ac5f52fefa7f2c7b4b0327268 Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Wed, 25 Jan 2023 09:40:19 -0600
Subject: [PATCH 12/13] _cli: fix sig output

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 sigstore/_cli.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sigstore/_cli.py b/sigstore/_cli.py
index bf4f924f8..440018f3f 100644
--- a/sigstore/_cli.py
+++ b/sigstore/_cli.py
@@ -725,7 +725,7 @@ def _sign(args: argparse.Namespace) -> None:
         print(f"Transparency log entry created at index: {result.log_entry.log_index}")
 
         sig_output: TextIO
-        if outputs["sig"] in outputs:
+        if "sig" in outputs:
             sig_output = outputs["sig"].open("w")
         else:
             sig_output = sys.stdout

From 24c247202060ff5f0f1b2fb1bbdc6fdcad18a523 Mon Sep 17 00:00:00 2001
From: William Woodruff <william@trailofbits.com>
Date: Wed, 25 Jan 2023 09:45:49 -0600
Subject: [PATCH 13/13] _cli: fix sig check, take 2

Signed-off-by: William Woodruff <william@trailofbits.com>
---
 sigstore/_cli.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sigstore/_cli.py b/sigstore/_cli.py
index 440018f3f..21fa28ff3 100644
--- a/sigstore/_cli.py
+++ b/sigstore/_cli.py
@@ -725,7 +725,7 @@ def _sign(args: argparse.Namespace) -> None:
         print(f"Transparency log entry created at index: {result.log_entry.log_index}")
 
         sig_output: TextIO
-        if "sig" in outputs:
+        if outputs["sig"] is not None:
             sig_output = outputs["sig"].open("w")
         else:
             sig_output = sys.stdout