Skip to content

Commit

Permalink
Initial Sigstore bundle support (#465)
Browse files Browse the repository at this point in the history
* Initial Sigstore bundle support

Signed-off-by: William Woodruff <[email protected]>

* README: update `--help` texts

Signed-off-by: William Woodruff <[email protected]>

* 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 <[email protected]>

* sign: remove TODO

Signed-off-by: William Woodruff <[email protected]>

* sign: update TODO

Signed-off-by: William Woodruff <[email protected]>

* _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 <[email protected]>

* _cli: Move variable to correct scope

Signed-off-by: Alex Cameron <[email protected]>

* _cli: Reword warnings for bundle flags

Signed-off-by: Alex Cameron <[email protected]>

* README: Fix sign example

Signed-off-by: Alex Cameron <[email protected]>

* README: Update verify invocations

Signed-off-by: Alex Cameron <[email protected]>

* README: Fix line breaks

Signed-off-by: Alex Cameron <[email protected]>

* _cli: fix sig output

Signed-off-by: William Woodruff <[email protected]>

* _cli: fix sig check, take 2

Signed-off-by: William Woodruff <[email protected]>

Signed-off-by: William Woodruff <[email protected]>
Signed-off-by: Alex Cameron <[email protected]>
Co-authored-by: Alex Cameron <[email protected]>
  • Loading branch information
woodruffw and tetsuo-cpp authored Jan 25, 2023
1 parent 6ae96b2 commit e919f5e
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 19 deletions.
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(
"--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:
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:
"""
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",
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

0 comments on commit e919f5e

Please sign in to comment.