diff --git a/CHANGELOG.md b/CHANGELOG.md index 899c5770..ab62ec1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ All versions prior to 0.9.0 are untracked. statement's predicate, which `sigstore-python` does not verify and should be verified by the user. +* CLI: The `sigstore attest` subcommand has been added. This command is + similar to `cosign attest` in that it signs over an artifact and a + predicate using a DSSE envelope. This commands requires the user to pass + a path to the file containing the predicate, and the predicate type. + Currently only the SLSA Provenance v0.2 and v1.0 types are supported. + ## [3.2.0] ### Added diff --git a/Makefile b/Makefile index 9b89be7d..61032b67 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,16 @@ check-readme: $(MAKE) -s run ARGS="sign --help" \ ) + # sigstore attest --help + @diff \ + <( \ + awk '/@begin-sigstore-attest-help@/{f=1;next} /@end-sigstore-attest-help@/{f=0} f' \ + < README.md | sed '1d;$$d' \ + ) \ + <( \ + $(MAKE) -s run ARGS="attest --help" \ + ) + # sigstore verify identity --help @diff \ <( \ diff --git a/README.md b/README.md index 0f64732b..33520873 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ a tool for signing and verifying Python package distributions positional arguments: COMMAND the operation to perform + attest sign one or more inputs using DSSE sign sign one or more inputs verify verify one or more inputs get-identity-token @@ -179,6 +180,58 @@ Output options: ``` + +### Signing with DSSE envelopes + + +``` +usage: sigstore attest [-h] [-v] --predicate FILE --predicate-type TYPE + [--identity-token TOKEN] [--oidc-client-id ID] + [--oidc-client-secret SECRET] + [--oidc-disable-ambient-providers] [--oidc-issuer URL] + [--oauth-force-oob] [--bundle FILE] [--overwrite] + FILE [FILE ...] + +positional arguments: + FILE The file to sign + +optional arguments: + -h, --help show this help message and exit + -v, --verbose run with additional debug logging; supply multiple + times to increase verbosity (default: 0) + +DSSE options: + --predicate FILE Path to the predicate file (default: None) + --predicate-type TYPE + Specify a predicate type + (https://slsa.dev/provenance/v0.2, + https://slsa.dev/provenance/v1) (default: None) + +OpenID Connect options: + --identity-token TOKEN + the OIDC identity token to use (default: None) + --oidc-client-id ID The custom OpenID Connect client ID to use during + OAuth2 (default: sigstore) + --oidc-client-secret SECRET + The custom OpenID Connect client secret to use during + OAuth2 (default: None) + --oidc-disable-ambient-providers + Disable ambient OpenID Connect credential detection + (e.g. on GitHub Actions) (default: False) + --oidc-issuer URL The OpenID Connect issuer to use (conflicts with + --staging) (default: https://oauth2.sigstore.dev/auth) + --oauth-force-oob Force an out-of-band OAuth flow and do not + automatically start the default web browser (default: + False) + +Output options: + --bundle FILE Write a single Sigstore bundle to the given file; does + not work with multiple input files (default: None) + --overwrite Overwrite preexisting bundle outputs, if present + (default: False) +``` + + ### Verifying #### Generic identities diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 4047044a..844aa216 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -19,16 +19,19 @@ import logging import os import sys +from dataclasses import dataclass from pathlib import Path -from typing import NoReturn, Optional, TextIO, Union +from typing import Dict, NoReturn, Optional, TextIO, Union from cryptography.hazmat.primitives.serialization import Encoding from cryptography.x509 import load_pem_x509_certificate +from pydantic import ValidationError from rich.console import Console from rich.logging import RichHandler from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import ( Bundle as RawBundle, ) +from typing_extensions import TypeAlias from sigstore import __version__, dsse from sigstore._internal.fulcio.client import ExpiredCertificate @@ -36,6 +39,15 @@ from sigstore._internal.rekor.client import RekorClient from sigstore._internal.trust import ClientTrustConfig from sigstore._utils import sha256_digest +from sigstore.dsse import StatementBuilder, Subject +from sigstore.dsse._predicate import ( + SUPPORTED_PREDICATE_TYPES, + Predicate, + PREDICATE_TYPE_SLSA_v0_2, + PREDICATE_TYPE_SLSA_v1_0, + SLSAPredicateV0_2, + SLSAPredicateV1_0, +) from sigstore.errors import Error, VerificationError from sigstore.hashes import Hashed from sigstore.models import Bundle, InvalidBundle @@ -64,6 +76,17 @@ _package_logger.setLevel(os.environ.get("SIGSTORE_LOGLEVEL", "INFO").upper()) +@dataclass(frozen=True) +class SigningOutputs: + signature: Optional[Path] = None + certificate: Optional[Path] = None + bundle: Optional[Path] = None + + +# Map of inputs -> outputs for signing operations +OutputMap: TypeAlias = Dict[Path, SigningOutputs] + + def _fatal(message: str) -> NoReturn: """ Logs a fatal condition and exits. @@ -228,6 +251,66 @@ def _parser() -> argparse.ArgumentParser: help="the operation to perform", ) + # `sigstore attest` + attest = subcommands.add_parser( + "attest", + help="sign one or more inputs using DSSE", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=[parent_parser], + ) + attest.add_argument( + "files", + metavar="FILE", + type=Path, + nargs="+", + help="The file to sign", + ) + + dsse_options = attest.add_argument_group("DSSE options") + dsse_options.add_argument( + "--predicate", + metavar="FILE", + type=Path, + required=True, + help="Path to the predicate file", + ) + dsse_options.add_argument( + "--predicate-type", + metavar="TYPE", + choices=SUPPORTED_PREDICATE_TYPES, + type=str, + required=True, + help=f"Specify a predicate type ({', '.join(SUPPORTED_PREDICATE_TYPES)})", + ) + + oidc_options = attest.add_argument_group("OpenID Connect options") + oidc_options.add_argument( + "--identity-token", + metavar="TOKEN", + type=str, + default=os.getenv("SIGSTORE_IDENTITY_TOKEN"), + help="the OIDC identity token to use", + ) + _add_shared_oidc_options(oidc_options) + + output_options = attest.add_argument_group("Output options") + 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" + ), + ) + output_options.add_argument( + "--overwrite", + action="store_true", + default=_boolify_env("SIGSTORE_OVERWRITE"), + help="Overwrite preexisting bundle outputs, if present", + ) + # `sigstore sign` sign = subcommands.add_parser( "sign", @@ -485,6 +568,8 @@ def main(args: list[str] | None = None) -> None: try: if args.subcommand == "sign": _sign(args) + elif args.subcommand == "attest": + _attest(args) elif args.subcommand == "verify": if args.verify_subcommand == "identity": _verify_identity(args) @@ -505,6 +590,157 @@ def main(args: list[str] | None = None) -> None: e.log_and_exit(_logger, args.verbose >= 1) +def _sign_common( + args: argparse.Namespace, output_map: OutputMap, predicate: Predicate | None +) -> None: + """ + Signing logic for both `sigstore sign` and `sigstore attest` + + Both `sign` and `attest` share the same signing logic, the only change is + whether they sign over a DSSE envelope or a hashedrekord. + This function differentiates between the two using the `predicate` argument. If + present, it will generate an in-toto statement and wrap it in a DSSE envelope. If + not, it will use a hashedrekord. + """ + # Select the signing context to use. + if args.staging: + _logger.debug("sign: staging instances requested") + signing_ctx = SigningContext.staging() + elif args.trust_config: + trust_config = ClientTrustConfig.from_json(args.trust_config.read_text()) + signing_ctx = SigningContext._from_trust_config(trust_config) + else: + # If the user didn't request the staging instance or pass in an + # explicit client trust config, we're using the public good (i.e. + # production) instance. + signing_ctx = SigningContext.production() + + # The order of precedence for identities is as follows: + # + # 1) Explicitly supplied identity token + # 2) Ambient credential detected in the environment, unless disabled + # 3) Interactive OAuth flow + identity: IdentityToken | None + if args.identity_token: + identity = IdentityToken(args.identity_token) + else: + identity = _get_identity(args) + + if not identity: + _invalid_arguments(args, "No identity token supplied or detected!") + + with signing_ctx.signer(identity) as signer: + for file, outputs in output_map.items(): + _logger.debug(f"signing for {file.name}") + with file.open(mode="rb") as io: + # The input can be indefinitely large, so we perform a streaming + # digest and sign the prehash rather than buffering it fully. + digest = sha256_digest(io) + try: + if predicate is None: + result = signer.sign_artifact(input_=digest) + else: + subject = Subject( + name=file.name, digest={"sha256": digest.digest.hex()} + ) + predicate_type = args.predicate_type + statement_builder = StatementBuilder( + subjects=[subject], + predicate_type=predicate_type, + # Dump by alias because while our Python models uses snake_case, + # the spec uses camelCase, which we have aliases for. + # We also exclude fields set to None, since it's how we model + # optional fields that were not set. + predicate=predicate.model_dump( + by_alias=True, exclude_none=True + ), + ) + result = signer.sign_dsse(statement_builder.build()) + except ExpiredIdentity as exp_identity: + print("Signature failed: identity token has expired") + raise exp_identity + + except ExpiredCertificate as exp_certificate: + print("Signature failed: Fulcio signing certificate has expired") + raise exp_certificate + + print("Using ephemeral certificate:") + cert = result.signing_certificate + cert_pem = cert.public_bytes(Encoding.PEM).decode() + print(cert_pem) + + print( + f"Transparency log entry created at index: {result.log_entry.log_index}" + ) + + sig_output: TextIO + if outputs.signature is not None: + sig_output = outputs.signature.open("w") + else: + sig_output = sys.stdout + + signature = base64.b64encode( + result._inner.message_signature.signature + ).decode() + print(signature, file=sig_output) + if outputs.signature is not None: + print(f"Signature written to {outputs.signature}") + + if outputs.certificate is not None: + with outputs.certificate.open(mode="w") as io: + print(cert_pem, file=io) + print(f"Certificate written to {outputs.certificate}") + + if outputs.bundle is not None: + with outputs.bundle.open(mode="w") as io: + print(result.to_json(), file=io) + print(f"Sigstore bundle written to {outputs.bundle}") + + +def _attest(args: argparse.Namespace) -> None: + predicate_path = args.predicate + if not predicate_path.is_file(): + _invalid_arguments(args, f"Predicate must be a file: {predicate_path}") + + try: + with open(predicate_path, "r") as f: + if args.predicate_type == PREDICATE_TYPE_SLSA_v0_2: + predicate: Predicate = SLSAPredicateV0_2.model_validate_json(f.read()) + elif args.predicate_type == PREDICATE_TYPE_SLSA_v1_0: + predicate = SLSAPredicateV1_0.model_validate_json(f.read()) + else: + _invalid_arguments( + args, + f'Unsupported predicate type "{args.predicate_type}". Predicate type must be one of: {SUPPORTED_PREDICATE_TYPES}', + ) + except ValidationError as e: + _invalid_arguments( + args, f'Unable to parse predicate of type "{args.predicate_type}": {e}' + ) + + # Build up the map of inputs -> outputs ahead of any signing operations, + # so that we can fail early if overwriting without `--overwrite`. + output_map: OutputMap = {} + for file in args.files: + if not file.is_file(): + _invalid_arguments(args, f"Input must be a file: {file}") + + bundle = args.bundle + output_dir = file.parent + + if not bundle: + bundle = output_dir / f"{file.name}.sigstore.json" + + if bundle and bundle.exists() and not args.overwrite: + _invalid_arguments( + args, + "Refusing to overwrite outputs without --overwrite: " f"{bundle}", + ) + output_map[file] = SigningOutputs(bundle=bundle) + + _sign_common(args, output_map=output_map, predicate=predicate) + + def _sign(args: argparse.Namespace) -> None: has_sig = bool(args.signature) has_crt = bool(args.certificate) @@ -541,7 +777,7 @@ 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: dict[Path, dict[str, Path | None]] = {} + output_map: OutputMap = {} for file in args.files: if not file.is_file(): _invalid_arguments(args, f"Input must be a file: {file}") @@ -578,87 +814,11 @@ def _sign(args: argparse.Namespace) -> None: f"{', '.join(extants)}", ) - output_map[file] = { - "cert": cert, - "sig": sig, - "bundle": bundle, - } - - # Select the signing context to use. - if args.staging: - _logger.debug("sign: staging instances requested") - signing_ctx = SigningContext.staging() - elif args.trust_config: - trust_config = ClientTrustConfig.from_json(args.trust_config.read_text()) - signing_ctx = SigningContext._from_trust_config(trust_config) - else: - # If the user didn't request the staging instance or pass in an - # explicit client trust config, we're using the public good (i.e. - # production) instance. - signing_ctx = SigningContext.production() - - # The order of precedence for identities is as follows: - # - # 1) Explicitly supplied identity token - # 2) Ambient credential detected in the environment, unless disabled - # 3) Interactive OAuth flow - identity: IdentityToken | None - if args.identity_token: - identity = IdentityToken(args.identity_token) - else: - identity = _get_identity(args) - - if not identity: - _invalid_arguments(args, "No identity token supplied or detected!") - - with signing_ctx.signer(identity) as signer: - for file, outputs in output_map.items(): - _logger.debug(f"signing for {file.name}") - with file.open(mode="rb") as io: - # The input can be indefinitely large, so we perform a streaming - # digest and sign the prehash rather than buffering it fully. - digest = sha256_digest(io) - try: - result = signer.sign_artifact(input_=digest) - except ExpiredIdentity as exp_identity: - print("Signature failed: identity token has expired") - raise exp_identity - - except ExpiredCertificate as exp_certificate: - print("Signature failed: Fulcio signing certificate has expired") - raise exp_certificate - - print("Using ephemeral certificate:") - cert = result.signing_certificate - cert_pem = cert.public_bytes(Encoding.PEM).decode() - print(cert_pem) - - print( - f"Transparency log entry created at index: {result.log_entry.log_index}" - ) - - sig_output: TextIO - if outputs["sig"] is not None: - sig_output = outputs["sig"].open("w") - else: - sig_output = sys.stdout - - signature = base64.b64encode( - result._inner.message_signature.signature - ).decode() - print(signature, file=sig_output) - if outputs["sig"] is not None: - print(f"Signature written to {outputs['sig']}") - - if outputs["cert"] is not None: - with outputs["cert"].open(mode="w") as io: - print(cert_pem, file=io) - print(f"Certificate written to {outputs['cert']}") + output_map[file] = SigningOutputs( + signature=sig, certificate=cert, bundle=bundle + ) - if outputs["bundle"] is not None: - with outputs["bundle"].open(mode="w") as io: - print(result.to_json(), file=io) - print(f"Sigstore bundle written to {outputs['bundle']}") + _sign_common(args, output_map=output_map, predicate=None) def _collect_verification_state( diff --git a/sigstore/dsse.py b/sigstore/dsse/__init__.py similarity index 99% rename from sigstore/dsse.py rename to sigstore/dsse/__init__.py index a914985d..d1a0b949 100644 --- a/sigstore/dsse.py +++ b/sigstore/dsse/__init__.py @@ -84,7 +84,7 @@ class Statement: This type deals with opaque bytes to ensure that the encoding does not change, but Statements are internally checked for conformance against - the JSON object layout defined in the in-toto attesation spec. + the JSON object layout defined in the in-toto attestation spec. See: """ diff --git a/sigstore/dsse/_predicate.py b/sigstore/dsse/_predicate.py new file mode 100644 index 00000000..0e628994 --- /dev/null +++ b/sigstore/dsse/_predicate.py @@ -0,0 +1,219 @@ +# Copyright 2024 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Models for the predicates used in in-toto statements +""" + +from typing import Any, Dict, List, Literal, Optional, TypeVar, Union + +from pydantic import ( + BaseModel, + ConfigDict, + RootModel, + StrictBytes, + StrictStr, + model_validator, +) +from pydantic.alias_generators import to_camel + +from sigstore.dsse import Digest + +PREDICATE_TYPE_SLSA_v0_2 = "https://slsa.dev/provenance/v0.2" +PREDICATE_TYPE_SLSA_v1_0 = "https://slsa.dev/provenance/v1" + +SUPPORTED_PREDICATE_TYPES = [PREDICATE_TYPE_SLSA_v0_2, PREDICATE_TYPE_SLSA_v1_0] + +# Common models +SourceDigest = Union[Literal["sha1"], Literal["gitCommit"]] +DigestSetSource = RootModel[Dict[Union[Digest, SourceDigest], str]] +""" +Same as `dsse.DigestSet` but with `sha1` added. + +Since this model is not used to verify hashes, but to parse predicates that might +contain hashes, we include this weak hash algorithm. This is because provenance +providers like GitHub use SHA1 in their predicates to refer to git commit hashes. +""" + + +class Predicate(BaseModel): + """ + Base model for in-toto predicates + """ + + pass + + +class _SLSAConfigBase(BaseModel): + """ + Base class used to configure the models + """ + + model_config = ConfigDict(alias_generator=to_camel) + + +# Models for SLSA Provenance v0.2 + + +class BuilderV0_1(_SLSAConfigBase): + """ + The Builder object used by SLSAPredicateV0_2 + """ + + id: StrictStr + + +class ConfigSource(_SLSAConfigBase): + """ + The ConfigSource object used by Invocation in v0.2 + """ + + uri: Optional[StrictStr] = None + digest: Optional[DigestSetSource] = None + entry_point: Optional[StrictStr] = None + + +class Invocation(_SLSAConfigBase): + """ + The Invocation object used by SLSAPredicateV0_2 + """ + + config_source: Optional[ConfigSource] = None + parameters: Optional[Dict[str, Any]] = None + environment: Optional[Dict[str, Any]] = None + + +class Completeness(_SLSAConfigBase): + """ + The Completeness object used by Metadata in v0.2 + """ + + parameters: Optional[bool] = None + environment: Optional[bool] = None + materials: Optional[bool] = None + + +class Material(_SLSAConfigBase): + """ + The Material object used by Metadata in v0.2 + """ + + uri: Optional[StrictStr] = None + digest: Optional[DigestSetSource] = None + + +class Metadata(_SLSAConfigBase): + """ + The Metadata object used by SLSAPredicateV0_2 + """ + + build_invocation_id: Optional[StrictStr] = None + build_started_on: Optional[StrictStr] = None + build_finished_on: Optional[StrictStr] = None + completeness: Optional[Completeness] = None + reproducible: Optional[bool] = None + + +class SLSAPredicateV0_2(Predicate, _SLSAConfigBase): + """ + Represents the predicate object corresponding to the type "https://slsa.dev/provenance/v0.2" + """ + + builder: BuilderV0_1 + build_type: StrictStr + invocation: Optional[Invocation] = None + metadata: Optional[Metadata] = None + build_config: Optional[Dict[str, Any]] = None + materials: Optional[List[Material]] = None + + +# Models for SLSA Provenance v1.0 + +Self = TypeVar("Self", bound="ResourceDescriptor") + + +class ResourceDescriptor(_SLSAConfigBase): + """ + The ResourceDescriptor object defined defined by the in-toto attestations spec + """ + + name: Optional[StrictStr] = None + uri: Optional[StrictStr] = None + digest: Optional[DigestSetSource] = None + content: Optional[StrictBytes] = None + download_location: Optional[StrictStr] = None + media_type: Optional[StrictStr] = None + annotations: Optional[Dict[StrictStr, Any]] = None + + @model_validator(mode="after") + def check_required_fields(self: Self) -> Self: + """ + While all fields are optional, at least one of the fields `uri`, `digest` or + `content` must be present + """ + if not self.uri and not self.digest and not self.content: + raise ValueError( + "A ResourceDescriptor MUST specify one of uri, digest or content at a minimum" + ) + return self + + +class BuilderV1_0(_SLSAConfigBase): + """ + The Builder object used by RunDetails in v1.0 + """ + + id: StrictStr + builder_dependencies: Optional[List[ResourceDescriptor]] = None + version: Optional[Dict[StrictStr, StrictStr]] = None + + +class BuildMetadata(_SLSAConfigBase): + """ + The BuildMetadata object used by RunDetails + """ + + invocation_id: Optional[StrictStr] = None + started_on: Optional[StrictStr] = None + finished_on: Optional[StrictStr] = None + + +class RunDetails(_SLSAConfigBase): + """ + The RunDetails object used by SLSAPredicateV1_0 + """ + + builder: BuilderV1_0 + metadata: Optional[BuildMetadata] = None + byproducts: Optional[List[ResourceDescriptor]] = None + + +class BuildDefinition(_SLSAConfigBase): + """ + The BuildDefinition object used by SLSAPredicateV1_0 + """ + + build_type: StrictStr + external_parameters: Dict[StrictStr, Any] + internal_parameters: Optional[Dict[str, Any]] = None + resolved_dependencies: Optional[List[ResourceDescriptor]] = None + + +class SLSAPredicateV1_0(Predicate, _SLSAConfigBase): + """ + Represents the predicate object corresponding to the type "https://slsa.dev/provenance/v1" + """ + + build_definition: BuildDefinition + run_details: RunDetails