Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Sigstore bundle support #465

Merged
merged 16 commits into from
Jan 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ build
*.sh
*.pub
*.rekor
*.sigstore

# Don't ignore these files when we intend to include them
!sigstore/_store/*.crt
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ 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]
[--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 ...]

Expand Down Expand Up @@ -169,6 +170,13 @@ Output options:
Write a single offline Rekor bundle to the given file;
does not work with multiple input files (default:
None)
--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)

Expand Down Expand Up @@ -205,7 +213,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 FILE]
--cert-identity IDENTITY
[--require-rekor-offline] --cert-oidc-issuer
URL [--staging] [--rekor-url URL]
[--rekor-root-pubkey FILE]
Expand All @@ -223,6 +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 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:
Expand Down Expand Up @@ -271,7 +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] --cert-identity IDENTITY
[--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]
Expand All @@ -290,6 +304,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 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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 94 additions & 15 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -185,6 +185,16 @@ 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",
metavar="FILE",
type=Path,
default=os.getenv("SIGSTORE_BUNDLE"),
help=(
"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(
"files",
metavar="FILE",
Expand Down Expand Up @@ -340,6 +350,25 @@ def _parser() -> argparse.ArgumentParser:
"multiple input files"
),
)
output_options.add_argument(
di marked this conversation as resolved.
Show resolved Hide resolved
"--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=False,
help=(
"Don't emit {input}.sigstore files for each input; this option is experimental "
"and may change between releases until stabilized"
),
)
output_options.add_argument(
"--overwrite",
action="store_true",
Expand Down Expand Up @@ -387,7 +416,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)
Expand Down Expand Up @@ -420,7 +449,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)
Expand Down Expand Up @@ -556,16 +585,37 @@ 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.
if args.bundle:
logger.warning(
"--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; the behaviour of this flag may change "
"between releases until stabilized."
)

# `--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 `--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(
Expand All @@ -583,18 +633,33 @@ def _sign(args: argparse.Namespace) -> None:
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, 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"
bundle = file.parent / f"{file.name}.rekor"
rekor_bundle = file.parent / f"{file.name}.rekor"
if not args.no_bundle:
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 rekor_bundle and rekor_bundle.exists():
extants.append(str(rekor_bundle))
if bundle and bundle.exists():
extants.append(str(bundle))

Expand All @@ -604,7 +669,12 @@ def _sign(args: argparse.Namespace) -> None:
f"{', '.join(extants)}"
)

output_map[file] = {"cert": cert, "sig": sig, "bundle": bundle}
output_map[file] = {
"cert": cert,
"sig": sig,
"rekor_bundle": rekor_bundle,
"bundle": bundle,
}

# Select the signer to use.
if args.staging:
Expand Down Expand Up @@ -655,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"]:
if outputs["sig"] is not None:
sig_output = outputs["sig"].open("w")
else:
sig_output = sys.stdout
Expand All @@ -669,11 +739,16 @@ def _sign(args: argparse.Namespace) -> None:
print(result.cert_pem, file=io)
print(f"Certificate written to {outputs['cert']}")

if outputs["rekor_bundle"] is not None:
Copy link
Contributor

@tetsuo-cpp tetsuo-cpp Jan 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed these back from:

if "rekor_bundle" in outputs:

This is because when we don't write to an output, the entry still gets added to the map, the file is just set to None. Previously, if specified some outputs but not all, we'd crash.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! I've re-fixed #465 (comment) based on that.

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 outputs["bundle"] is not None:
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(
Expand All @@ -687,6 +762,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:
Expand Down
80 changes: 80 additions & 0 deletions sigstore/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -188,3 +212,59 @@ class SigningResult(BaseModel):
"""
A record of the Rekor log entry for the signing operation.
"""

def _to_bundle(self) -> Bundle:
di marked this conversation as resolved.
Show resolved Hide resolved
"""
Creates a Sigstore bundle (as defined by Sigstore's protobuf specs)
from this `SigningResult`.
"""

# 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)])

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)),
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,
canonicalized_body=base64.b64decode(self.log_entry.body),
)

material = VerificationMaterial(
x509_certificate_chain=chain,
tlog_entries=[tlog_entry],
)

bundle = Bundle(
media_type="application/vnd.dev.sigstore.bundle+json;version=0.1",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice if there was some way to default to this media_type, but I have no idea if that's possible 🙂

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC no :(

That said, I think that's sort-of by design? You should only set that media_type if you immediately plan to serialize to JSON

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, I missed that detail! That makes sense, then, and it's not a huge deal here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it would be nice to have const values in the language bindings that would capture the current media type used.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% agreed -- I'm not sure how best to accomplish that without hacky patches to the codegen, though...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, would be messy. I would love for Protobufs to support that natively, so many use-cases I have seen where it would make our lives easier.

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