Skip to content

Commit

Permalink
cli: allow DSSE verification (#1015)
Browse files Browse the repository at this point in the history
  • Loading branch information
woodruffw authored May 16, 2024
1 parent 09da22b commit dbab104
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 51 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ All versions prior to 0.9.0 are untracked.
for representing in-toto statements and DSSE envelopes
([#930](https://github.com/sigstore/sigstore-python/pull/930))

* CLI: The `sigstore verify` subcommands can now verify bundles containing
DSSE entries, such as those produced by
[GitHub Artifact Attestations](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)
([#1015](https://github.com/sigstore/sigstore-python/pull/1015))

### Removed

* **BREAKING API CHANGE**: `SigningResult` has been removed.
Expand Down Expand Up @@ -87,6 +92,13 @@ All versions prior to 0.9.0 are untracked.
contains. No functional changes have been made to it
([#1016](https://github.com/sigstore/sigstore-python/pull/1016))

* API: `policy.Identity` now takes an **optional** OIDC issuer, rather than a
required one ([#1015](https://github.com/sigstore/sigstore-python/pull/1015))

* CLI: `sigstore verify github` now requires `--cert-identity` **or**
`--repository`, not just `--cert-identity`
([#1015](https://github.com/sigstore/sigstore-python/pull/1015))

## [2.1.5]

## Fixed
Expand Down
24 changes: 11 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,9 @@ to by a particular OIDC provider (like `https://github.com/login/oauth`).
<!-- @begin-sigstore-verify-identity-help@ -->
```
usage: sigstore verify identity [-h] [-v] [--certificate FILE]
[--signature FILE] [--bundle FILE]
--cert-identity IDENTITY [--offline]
--cert-oidc-issuer URL [--staging]
[--rekor-url URL]
[--signature FILE] [--bundle FILE] [--offline]
--cert-identity IDENTITY --cert-oidc-issuer
URL [--staging] [--rekor-url URL]
FILE [FILE ...]

optional arguments:
Expand All @@ -227,11 +226,11 @@ Verification inputs:
FILE The file to verify

Verification options:
--offline Perform offline verification; requires a Sigstore
bundle (default: False)
--cert-identity IDENTITY
The identity to check for in the certificate's Subject
Alternative Name (default: None)
--offline Perform offline verification; requires a Sigstore
bundle (default: False)
--cert-oidc-issuer URL
The OIDC issuer URL to check for in the certificate's
OIDC issuer extension (default: None)
Expand All @@ -258,11 +257,10 @@ claims more precisely than `sigstore verify identity` allows:
<!-- @begin-sigstore-verify-github-help@ -->
```
usage: sigstore verify github [-h] [-v] [--certificate FILE]
[--signature FILE] [--bundle FILE]
--cert-identity IDENTITY [--offline]
[--trigger EVENT] [--sha SHA] [--name NAME]
[--repository REPO] [--ref REF] [--staging]
[--rekor-url URL]
[--signature FILE] [--bundle FILE] [--offline]
[--cert-identity IDENTITY] [--trigger EVENT]
[--sha SHA] [--name NAME] [--repository REPO]
[--ref REF] [--staging] [--rekor-url URL]
FILE [FILE ...]

optional arguments:
Expand All @@ -281,11 +279,11 @@ Verification inputs:
FILE The file to verify

Verification options:
--offline Perform offline verification; requires a Sigstore
bundle (default: False)
--cert-identity IDENTITY
The identity to check for in the certificate's Subject
Alternative Name (default: None)
--offline Perform offline verification; requires a Sigstore
bundle (default: False)
--trigger EVENT The GitHub Actions event name that triggered the
workflow (default: None)
--sha SHA The `git` commit SHA that the workflow run was invoked
Expand Down
101 changes: 75 additions & 26 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from cryptography.x509 import load_pem_x509_certificate
from rich.logging import RichHandler

from sigstore import __version__
from sigstore import __version__, dsse
from sigstore._internal.fulcio.client import (
DEFAULT_FULCIO_URL,
ExpiredCertificate,
Expand Down Expand Up @@ -160,14 +160,6 @@ def _add_shared_verify_input_options(group: argparse._ArgumentGroup) -> None:


def _add_shared_verification_options(group: argparse._ArgumentGroup) -> None:
group.add_argument(
"--cert-identity",
metavar="IDENTITY",
type=str,
default=os.getenv("SIGSTORE_CERT_IDENTITY"),
help="The identity to check for in the certificate's Subject Alternative Name",
required=True,
)
group.add_argument(
"--offline",
action="store_true",
Expand Down Expand Up @@ -376,6 +368,14 @@ def _parser() -> argparse.ArgumentParser:

verification_options = verify_identity.add_argument_group("Verification options")
_add_shared_verification_options(verification_options)
verification_options.add_argument(
"--cert-identity",
metavar="IDENTITY",
type=str,
default=os.getenv("SIGSTORE_CERT_IDENTITY"),
help="The identity to check for in the certificate's Subject Alternative Name",
required=True,
)
verification_options.add_argument(
"--cert-oidc-issuer",
metavar="URL",
Expand All @@ -401,6 +401,13 @@ def _parser() -> argparse.ArgumentParser:

verification_options = verify_github.add_argument_group("Verification options")
_add_shared_verification_options(verification_options)
verification_options.add_argument(
"--cert-identity",
metavar="IDENTITY",
type=str,
default=os.getenv("SIGSTORE_CERT_IDENTITY"),
help="The identity to check for in the certificate's Subject Alternative Name",
)
verification_options.add_argument(
"--trigger",
dest="workflow_trigger",
Expand Down Expand Up @@ -813,28 +820,36 @@ def _verify_identity(args: argparse.Namespace) -> None:
)

try:
verifier.verify_artifact(
input_=hashed,
bundle=bundle,
policy=policy_,
)
_verify_common(verifier, hashed, bundle, policy_)
print(f"OK: {file}")
except VerificationError as exc:
except Error as exc:
_logger.error(f"FAIL: {file}")
exc.log_and_exit(_logger, args.verbose >= 1)


def _verify_github(args: argparse.Namespace) -> None:
# Every GitHub verification begins with an identity policy,
# for which we know the issuer URL ahead of time.
# We then add more policies, as configured by the user's passed-in options.
inner_policies: list[policy.VerificationPolicy] = [
policy.Identity(
identity=args.cert_identity,
issuer="https://token.actions.githubusercontent.com",
inner_policies: list[policy.VerificationPolicy] = []

# We require at least one of `--cert-identity` or `--repository`,
# to minimize the risk of user confusion about what's being verified.
if not (args.cert_identity or args.workflow_repository):
_die(args, "--cert-identity or --repository is required")

# No matter what the user configures above, we require the OIDC issuer to
# be GitHub Actions.
inner_policies.append(
policy.OIDCIssuer("https://token.actions.githubusercontent.com")
)

if args.cert_identity:
inner_policies.append(
policy.Identity(
identity=args.cert_identity,
# We always explicitly check the issuer below, so configuring
# it here is unnecessary.
issuer=None,
)
)
]

if args.workflow_trigger:
inner_policies.append(policy.GitHubWorkflowTrigger(args.workflow_trigger))
if args.workflow_sha:
Expand All @@ -851,13 +866,47 @@ def _verify_github(args: argparse.Namespace) -> None:
verifier, materials = _collect_verification_state(args)
for file, hashed, bundle in materials:
try:
verifier.verify_artifact(input_=hashed, bundle=bundle, policy=policy_)
_verify_common(verifier, hashed, bundle, policy_)
print(f"OK: {file}")
except VerificationError as exc:
except Error as exc:
_logger.error(f"FAIL: {file}")
exc.log_and_exit(_logger, args.verbose >= 1)


def _verify_common(
verifier: Verifier,
hashed: Hashed,
bundle: Bundle,
policy_: policy.VerificationPolicy,
) -> None:
"""
Common verification handling.
This dispatches to either artifact or DSSE verification, depending on
`bundle`'s inner type.
"""

# If the bundle specifies a DSSE envelope, perform DSSE verification
# and assert that the inner payload is an in-toto statement bound
# to a subject matching the input's digest.
if bundle._dsse_envelope:
type_, payload = verifier.verify_dsse(bundle=bundle, policy=policy_)
if type_ != dsse.Envelope._TYPE:
raise VerificationError(f"expected JSON payload for DSSE, got {type_}")

stmt = dsse.Statement(payload)
if not stmt._matches_digest(hashed):
raise VerificationError(
f"in-toto statement has no subject for digest {hashed.digest.hex()}"
)
else:
verifier.verify_artifact(
input_=hashed,
bundle=bundle,
policy=policy_,
)


def _get_identity(args: argparse.Namespace) -> Optional[IdentityToken]:
token = None
if not args.oidc_disable_ambient_providers:
Expand Down
29 changes: 25 additions & 4 deletions sigstore/dsse.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from pydantic import BaseModel, ConfigDict, Field, RootModel, StrictStr, ValidationError
from sigstore_protobuf_specs.dev.sigstore.common.v1 import HashAlgorithm
from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope
from sigstore_protobuf_specs.io.intoto import Signature

from sigstore.errors import VerificationError
from sigstore.errors import Error, VerificationError
from sigstore.hashes import Hashed

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -97,9 +99,28 @@ def __init__(self, contents: bytes) -> None:
"""
self._contents = contents
try:
self._statement = _Statement.model_validate_json(contents)
self._inner = _Statement.model_validate_json(contents)
except ValidationError:
raise ValueError("malformed in-toto statement")
raise Error("malformed in-toto statement")

def _matches_digest(self, digest: Hashed) -> bool:
"""
Returns a boolean indicating whether this in-toto Statement contains a subject
matching the given digest. The subject's name is **not** checked.
No digests other than SHA256 are currently supported.
"""
if digest.algorithm != HashAlgorithm.SHA2_256:
raise VerificationError(f"unexpected digest algorithm: {digest.algorithm}")

for sub in self._inner.subjects:
sub_digest = sub.digest.root.get("sha256")
if sub_digest is None:
continue
if sub_digest == digest.digest.hex():
return True

return False

def _pae(self) -> bytes:
"""
Expand Down Expand Up @@ -160,7 +181,7 @@ def build(self) -> Statement:
predicate=self._predicate,
)
except ValidationError as e:
raise ValueError(f"invalid statement: {e}")
raise Error(f"invalid statement: {e}")

return Statement(stmt.model_dump_json(by_alias=True).encode())

Expand Down
6 changes: 4 additions & 2 deletions sigstore/hashes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from pydantic import BaseModel
from sigstore_protobuf_specs.dev.sigstore.common.v1 import HashAlgorithm

from sigstore.errors import Error


class Hashed(BaseModel):
"""
Expand All @@ -44,12 +46,12 @@ def _as_hashedrekord_algorithm(self) -> rekor_types.hashedrekord.Algorithm:
"""
if self.algorithm == HashAlgorithm.SHA2_256:
return rekor_types.hashedrekord.Algorithm.SHA256
raise ValueError(f"unknown hash algorithm: {self.algorithm}")
raise Error(f"unknown hash algorithm: {self.algorithm}")

def _as_prehashed(self) -> Prehashed:
"""
Returns an appropriate Cryptography `Prehashed` for this `Hashed`.
"""
if self.algorithm == HashAlgorithm.SHA2_256:
return Prehashed(hashes.SHA256())
raise ValueError(f"unknown hash algorithm: {self.algorithm}")
raise Error(f"unknown hash algorithm: {self.algorithm}")
19 changes: 13 additions & 6 deletions sigstore/verify/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def verify(self, cert: Certificate) -> None:
raise VerificationError(
(
f"Certificate's {self.__class__.__name__} does not match "
f"(got {ext_value}, expected {self._value})"
f"(got '{ext_value}', expected '{self._value}')"
)
)

Expand Down Expand Up @@ -441,26 +441,33 @@ def verify(self, cert: Certificate) -> None:
class Identity:
"""
Verifies the certificate's "identity", corresponding to the X.509v3 SAN.
Identities are verified modulo an OIDC issuer, so the issuer's URI
is also required.
Identities can be verified modulo an OIDC issuer, to prevent an unexpected
issuer from offering a particular identity.
Supported SAN types include emails, URIs, and Sigstore-specific "other names".
"""

def __init__(self, *, identity: str, issuer: str):
_issuer: OIDCIssuer | None

def __init__(self, *, identity: str, issuer: str | None = None):
"""
Create a new `Identity`, with the given expected identity and issuer values.
"""

self._identity = identity
self._issuer = OIDCIssuer(issuer)
if issuer:
self._issuer = OIDCIssuer(issuer)
else:
self._issuer = None

def verify(self, cert: Certificate) -> None:
"""
Verify `cert` against the policy.
"""

self._issuer.verify(cert)
if self._issuer:
self._issuer.verify(cert)

# Build a set of all valid identities.
san_ext = cert.extensions.get_extension_for_class(SubjectAlternativeName).value
Expand Down

0 comments on commit dbab104

Please sign in to comment.