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 2 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
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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 ...]

Expand All @@ -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:
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
106 changes: 78 additions & 28 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,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",
Expand Down Expand Up @@ -340,6 +349,15 @@ def _parser() -> argparse.ArgumentParser:
"multiple input files"
),
)
output_options.add_argument(
di marked this conversation as resolved.
Show resolved Hide resolved
"--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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
83 changes: 83 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,62 @@ 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: Should we include our Fulcio intermediate in the chain?
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
chain = X509CertificateChain(
certificates=[X509Certificate(raw_bytes=self.cert_pem.encode())]
)
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

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"),
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
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(),
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
)

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