diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa5ad5bf..e914b5e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ All versions prior to 0.9.0 are untracked. stable version of `sigstore-python` ([#379](https://github.com/sigstore/sigstore-python/pull/379)) +* `sigstore` now has a public, importable Python API! You can find its + documentation [here](https://sigstore.github.io/sigstore-python/) + ([#383](https://github.com/sigstore/sigstore-python/pull/383)) + +* `sigstore --staging` is now the intended way to request Sigstore's staging + instance, rather than per-subcommand options like `sigstore sign --staging`. + The latter is unchanged, but will be marked deprecated in a future stable + version of `sigstore-python` + ([#383](https://github.com/sigstore/sigstore-python/pull/383)) + ### Changed * The default behavior of `SIGSTORE_LOGLEVEL` has changed; the logger diff --git a/README.md b/README.md index 1f5104134..5188cb0a4 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ Top-level: ``` -usage: sigstore [-h] [-V] [-v] {sign,verify,get-identity-token} ... +usage: sigstore [-h] [-V] [-v] [--staging] + {sign,verify,get-identity-token} ... a tool for signing and verifying Python package distributions @@ -83,6 +84,10 @@ optional arguments: -V, --version show program's version number and exit -v, --verbose run with additional debug logging; supply multiple times to increase verbosity (default: 0) + +Sigstore instance options: + --staging Use sigstore's staging instances, instead of the + default production instances (default: False) ``` @@ -138,7 +143,9 @@ Output options: Sigstore instance options: --staging Use sigstore's staging instances, instead of the - default production instances (default: False) + default production instances. This option will be + deprecated in favor of the global `--staging` option + in a future release. (default: False) --rekor-url URL The Rekor instance to use (conflicts with --staging) (default: https://rekor.sigstore.dev) --rekor-root-pubkey FILE @@ -205,7 +212,9 @@ Extended verification options: Sigstore instance options: --staging Use sigstore's staging instances, instead of the - default production instances (default: False) + default production instances. This option will be + deprecated in favor of the global `--staging` option + in a future release. (default: False) --rekor-url URL The Rekor instance to use (conflicts with --staging) (default: https://rekor.sigstore.dev) --rekor-root-pubkey FILE diff --git a/pyproject.toml b/pyproject.toml index 011999fe2..18b681eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ disallow_untyped_defs = true ignore_missing_imports = true no_implicit_optional = true show_error_codes = true +sqlite_cache = true strict_equality = true warn_no_return = true warn_redundant_casts = true diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 216aea6fa..d00ce369f 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -26,25 +26,22 @@ from sigstore import __version__ from sigstore._internal.ctfe import CTKeyring from sigstore._internal.fulcio.client import DEFAULT_FULCIO_URL, FulcioClient -from sigstore._internal.oidc.ambient import ( - GitHubOidcPermissionCredentialError, - detect_credential, -) -from sigstore._internal.oidc.issuer import Issuer -from sigstore._internal.oidc.oauth import ( - DEFAULT_OAUTH_ISSUER, - STAGING_OAUTH_ISSUER, - get_identity_token, -) from sigstore._internal.rekor.client import ( DEFAULT_REKOR_URL, RekorBundle, RekorClient, - RekorEntry, ) from sigstore._internal.tuf import TrustUpdater -from sigstore._sign import Signer -from sigstore._verify import ( +from sigstore.oidc import ( + DEFAULT_OAUTH_ISSUER_URL, + STAGING_OAUTH_ISSUER_URL, + GitHubOidcPermissionCredentialError, + Issuer, + detect_credential, +) +from sigstore.rekor import RekorEntry +from sigstore.sign import Signer +from sigstore.verify import ( CertificateVerificationFailure, RekorEntryMissing, VerificationFailure, @@ -122,9 +119,14 @@ def _set_default_verify_subparser(parser: argparse.ArgumentParser, name: str) -> def _add_shared_instance_options(group: argparse._ArgumentGroup) -> None: group.add_argument( "--staging", + dest="__deprecated_staging", action="store_true", default=_boolify_env("SIGSTORE_STAGING"), - help="Use sigstore's staging instances, instead of the default production instances", + help=( + "Use sigstore's staging instances, instead of the default production instances. " + "This option will be deprecated in favor of the global `--staging` option " + "in a future release." + ), ) group.add_argument( "--rekor-url", @@ -169,7 +171,7 @@ def _add_shared_oidc_options( "--oidc-issuer", metavar="URL", type=str, - default=os.getenv("SIGSTORE_OIDC_ISSUER", DEFAULT_OAUTH_ISSUER), + default=os.getenv("SIGSTORE_OIDC_ISSUER", DEFAULT_OAUTH_ISSUER_URL), help="The OpenID Connect issuer to use (conflicts with --staging)", ) @@ -190,6 +192,15 @@ def _parser() -> argparse.ArgumentParser: default=0, help="run with additional debug logging; supply multiple times to increase verbosity", ) + + global_instance_options = parser.add_argument_group("Sigstore instance options") + global_instance_options.add_argument( + "--staging", + action="store_true", + default=_boolify_env("SIGSTORE_STAGING"), + help="Use sigstore's staging instances, instead of the default production instances", + ) + subcommands = parser.add_subparsers(required=True, dest="subcommand") # `sigstore sign` @@ -390,6 +401,15 @@ def main() -> None: logger.debug(f"parsed arguments {args}") + # `sigstore --staging some-cmd` is now the preferred form, rather than + # `sigstore some-cmd --staging`. + if getattr(args, "__deprecated_staging", False): + logger.warning( + "`--staging` should be used as a global option, rather than a subcommand option. " + "Passing `--staging` as a subcommand option will be deprecated in a future release." + ) + args.staging = args.__deprecated_staging + # Stuff the parser back into our namespace, so that we can use it for # error handling later. args._parser = parser @@ -472,7 +492,7 @@ def _sign(args: argparse.Namespace) -> None: if args.staging: logger.debug("sign: staging instances requested") signer = Signer.staging() - args.oidc_issuer = STAGING_OAUTH_ISSUER + args.oidc_issuer = STAGING_OAUTH_ISSUER_URL elif args.fulcio_url == DEFAULT_FULCIO_URL and args.rekor_url == DEFAULT_REKOR_URL: signer = Signer.production() else: @@ -533,7 +553,7 @@ def _sign(args: argparse.Namespace) -> None: if outputs["bundle"] is not None: with outputs["bundle"].open(mode="w") as io: - bundle = result.log_entry.to_bundle() + bundle = RekorBundle.from_entry(result.log_entry) print(bundle.json(by_alias=True), file=io) print(f"Rekor bundle written to {outputs['bundle']}") @@ -776,14 +796,18 @@ def _get_identity_token(args: argparse.Namespace) -> Optional[str]: sys.exit(1) if not token: - issuer = Issuer(args.oidc_issuer) + if args.staging: + issuer = Issuer.staging() + elif args.oidc_issuer == DEFAULT_OAUTH_ISSUER_URL: + issuer = Issuer.production() + else: + issuer = Issuer(args.oidc_issuer) if args.oidc_client_secret is None: args.oidc_client_secret = "" # nosec: B105 - token = get_identity_token( - args.oidc_client_id, - args.oidc_client_secret, - issuer, + token = issuer.identity_token( + client_id=args.oidc_client_id, client_secret=args.oidc_client_secret ) + return token diff --git a/sigstore/_internal/merkle.py b/sigstore/_internal/merkle.py index 1e84fbc6a..96a15231e 100644 --- a/sigstore/_internal/merkle.py +++ b/sigstore/_internal/merkle.py @@ -26,7 +26,7 @@ import struct from typing import List, Tuple -from sigstore._internal.rekor import RekorEntry +from sigstore.rekor import RekorEntry class InvalidInclusionProofError(Exception): diff --git a/sigstore/_internal/oidc/__init__.py b/sigstore/_internal/oidc/__init__.py index 8df6787cd..569a4ed33 100644 --- a/sigstore/_internal/oidc/__init__.py +++ b/sigstore/_internal/oidc/__init__.py @@ -18,6 +18,8 @@ import jwt +from sigstore.oidc import IdentityError + # See: https://github.com/sigstore/fulcio/blob/b2186c0/pkg/config/config.go#L182-L201 _KNOWN_OIDC_ISSUERS = { "https://accounts.google.com": "email", @@ -28,14 +30,6 @@ DEFAULT_AUDIENCE = "sigstore" -class IdentityError(Exception): - """ - Raised on any OIDC token format or claim error. - """ - - pass - - class Identity: """ A wrapper for an OIDC "identity", as extracted from an OIDC token. diff --git a/sigstore/_internal/oidc/ambient.py b/sigstore/_internal/oidc/ambient.py index aeee5a24a..849114a10 100644 --- a/sigstore/_internal/oidc/ambient.py +++ b/sigstore/_internal/oidc/ambient.py @@ -18,55 +18,25 @@ import logging import os -from typing import Callable, List, Optional +from typing import Optional import requests from pydantic import BaseModel, StrictStr -from sigstore._internal.oidc import DEFAULT_AUDIENCE, IdentityError +from sigstore._internal.oidc import DEFAULT_AUDIENCE +from sigstore.oidc import ( + AmbientCredentialError, + GitHubOidcPermissionCredentialError, +) logger = logging.getLogger(__name__) _GCP_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name" _GCP_TOKEN_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/token" # noqa # nosec B105 -_GCP_IDENTITY_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa # nosec B105 +_GCP_IDENTITY_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa _GCP_GENERATEIDTOKEN_REQUEST_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken" # noqa -class AmbientCredentialError(IdentityError): - """ - Raised when an ambient credential should be present, but - can't be retrieved (e.g. network failure). - """ - - pass - - -class GitHubOidcPermissionCredentialError(AmbientCredentialError): - """ - Raised when the current GitHub Actions environment doesn't have permission - to retrieve an OIDC token. - """ - - pass - - -def detect_credential() -> Optional[str]: - """ - Try each ambient credential detector, returning the first one to succeed - or `None` if all fail. - - Raises `AmbientCredentialError` if any detector fails internally (i.e. - detects a credential, but cannot retrieve it). - """ - detectors: List[Callable[..., Optional[str]]] = [detect_github, detect_gcp] - for detector in detectors: - credential = detector() - if credential is not None: - return credential - return None - - class _GitHubTokenPayload(BaseModel): """ A trivial model for GitHub's OIDC token endpoint payload. diff --git a/sigstore/_internal/oidc/issuer.py b/sigstore/_internal/oidc/issuer.py deleted file mode 100644 index c21de6fdc..000000000 --- a/sigstore/_internal/oidc/issuer.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2022 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. - -""" -Helper that queries the OpenID configuration for a given issuer and extracts its endpoints. -""" - -import urllib.parse - -import requests - - -class IssuerError(Exception): - """ - Raised on any communication or format error with an OIDC issuer. - """ - - pass - - -class Issuer: - """ - Represents an OIDC issuer (IdP). - """ - - def __init__(self, base_url: str) -> None: - """ - Create a new `Issuer` from the given base URL. - - This URL is used to locate an OpenID Connect configuration file, - which is then used to bootstrap the issuer's state (such - as authorization and token endpoints). - """ - oidc_config_url = urllib.parse.urljoin( - f"{base_url}/", ".well-known/openid-configuration" - ) - - resp: requests.Response = requests.get(oidc_config_url) - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise IssuerError from http_error - - struct = resp.json() - - try: - self.auth_endpoint: str = struct["authorization_endpoint"] - except KeyError as key_error: - raise IssuerError( - f"OIDC configuration does not contain authorization endpoint: {struct}" - ) from key_error - - try: - self.token_endpoint: str = struct["token_endpoint"] - except KeyError as key_error: - raise IssuerError( - f"OIDC configuration does not contain token endpoint: {struct}" - ) from key_error diff --git a/sigstore/_internal/oidc/oauth.py b/sigstore/_internal/oidc/oauth.py index e8c077734..21780c323 100644 --- a/sigstore/_internal/oidc/oauth.py +++ b/sigstore/_internal/oidc/oauth.py @@ -24,22 +24,14 @@ import logging import os import threading -import time import urllib.parse import uuid -import webbrowser from typing import Any, Dict, List, Optional, cast -import requests - -from sigstore._internal.oidc import IdentityError -from sigstore._internal.oidc.issuer import Issuer +from sigstore.oidc import IdentityError, Issuer logger = logging.getLogger(__name__) -DEFAULT_OAUTH_ISSUER = "https://oauth2.sigstore.dev/auth" -STAGING_OAUTH_ISSUER = "https://oauth2.sigstage.dev/auth" - # This HTML is copied from the Go Sigstore library and was originally authored by Julien Vermette: # https://github.com/sigstore/sigstore/blob/main/pkg/oauth/interactive.go @@ -259,68 +251,3 @@ def enable_oob(self) -> None: def is_oob(self) -> bool: return self._is_out_of_band - - -def get_identity_token(client_id: str, client_secret: str, issuer: Issuer) -> str: - """ - Retrieve an OpenID Connect token from the Sigstore provider - - This function and the components that it relies on are based off of: - https://github.com/psteniusubi/python-sample - """ - - force_oob = os.getenv("SIGSTORE_OAUTH_FORCE_OOB") is not None - - code: str - with _OAuthFlow(client_id, client_secret, issuer) as server: - # Launch web browser - if not force_oob and webbrowser.open(server.base_uri): - print("Waiting for browser interaction...") - else: - server.enable_oob() - print(f"Go to the following link in a browser:\n\n\t{server.auth_endpoint}") - - if not server.is_oob(): - # Wait until the redirect server populates the response - while server.auth_response is None: - time.sleep(0.1) - - auth_error = server.auth_response.get("error") - if auth_error is not None: - raise IdentityError( - f"Error response from auth endpoint: {auth_error[0]}" - ) - code = server.auth_response["code"][0] - else: - # In the out-of-band case, we wait until the user provides the code - code = input("Enter verification code: ") - - # Provide code to token endpoint - data = { - "grant_type": "authorization_code", - "redirect_uri": server.redirect_uri, - "code": code, - "code_verifier": server.oauth_session.code_verifier, - } - auth = ( - client_id, - client_secret, - ) - logging.debug(f"PAYLOAD: data={data}") - resp: requests.Response = requests.post( - issuer.token_endpoint, - data=data, - auth=auth, - ) - - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise IdentityError from http_error - - token_json = resp.json() - token_error = token_json.get("error") - if token_error is not None: - raise IdentityError(f"Error response from token endpoint: {token_error}") - - return str(token_json["access_token"]) diff --git a/sigstore/_internal/rekor/__init__.py b/sigstore/_internal/rekor/__init__.py index e04f57322..087e8a6da 100644 --- a/sigstore/_internal/rekor/__init__.py +++ b/sigstore/_internal/rekor/__init__.py @@ -16,6 +16,6 @@ APIs for interacting with Rekor. """ -from .client import RekorClient, RekorEntry, RekorInclusionProof +from .client import RekorClient -__all__ = ["RekorClient", "RekorEntry", "RekorInclusionProof"] +__all__ = ["RekorClient"] diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 95fedb9e6..4b95fb124 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -22,19 +22,19 @@ import logging from abc import ABC from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from urllib.parse import urljoin import requests from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509 import Certificate -from pydantic import BaseModel, Field, StrictInt, StrictStr, validator -from securesystemslib.formats import encode_canonical +from pydantic import BaseModel, Field, StrictInt, StrictStr from sigstore._internal.ctfe import CTKeyring from sigstore._internal.tuf import TrustUpdater from sigstore._utils import base64_encode_pem_cert +from sigstore.rekor import RekorEntry logger = logging.getLogger(__name__) @@ -82,114 +82,22 @@ def to_entry(self) -> RekorEntry: signed_entry_timestamp=self.signed_entry_timestamp, ) - -@dataclass(frozen=True) -class RekorEntry: - """ - Represents a Rekor log entry. - - Log entries are retrieved from Rekor after signing or verification events, - or generated from "offline" Rekor bundles supplied by the user. - """ - - uuid: Optional[str] - """ - This entry's unique ID in the Rekor instance it was retrieved from. - - For sharded Rekor deployments, IDs are unique per-shard. - - Not present for `RekorEntry` instances loaded from offline bundles. - """ - - body: str - """ - The base64-encoded body of the Rekor entry. - """ - - integrated_time: int - """ - The UNIX time at which this entry was integrated into the Rekor log. - """ - - log_id: str - """ - The log's ID (as the SHA256 hash of the DER-encoded public key for the log - at the time of entry inclusion). - """ - - log_index: int - """ - The index of this entry within the log. - """ - - inclusion_proof: Optional[RekorInclusionProof] - """ - An optional inclusion proof for this log entry. - - Only present for entries retrieved from online logs. - """ - - signed_entry_timestamp: str - """ - The base64-encoded Signed Entry Timestamp (SET) for this log entry. - """ - @classmethod - def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry: - """ - Create a new `RekorEntry` from the given API response. - """ - - # Assumes we only get one entry back - entries = list(dict_.items()) - if len(entries) != 1: - raise RekorClientError("Received multiple entries in response") - - uuid, entry = entries[0] - - return cls( - uuid=uuid, - body=entry["body"], - integrated_time=entry["integratedTime"], - log_id=entry["logID"], - log_index=entry["logIndex"], - inclusion_proof=RekorInclusionProof.parse_obj( - entry["verification"]["inclusionProof"] - ), - signed_entry_timestamp=entry["verification"]["signedEntryTimestamp"], - ) - - def to_bundle(self) -> RekorBundle: + def from_entry(cls, entry: RekorEntry) -> RekorBundle: """ Returns a `RekorBundle` for this `RekorEntry`. """ return RekorBundle( - signed_entry_timestamp=self.signed_entry_timestamp, + signed_entry_timestamp=entry.signed_entry_timestamp, payload=RekorBundle._Payload( - body=self.body, - integrated_time=self.integrated_time, - log_index=self.log_index, - log_id=self.log_id, + body=entry.body, + integrated_time=entry.integrated_time, + log_index=entry.log_index, + log_id=entry.log_id, ), ) - def encode_canonical(self) -> bytes: - """ - Returns a canonicalized JSON (RFC 8785) representation of the Rekor log entry. - - This encoded representation is suitable for verification against - the Signed Entry Timestamp. - """ - payload = { - "body": self.body, - "integratedTime": self.integrated_time, - "logID": self.log_id, - "logIndex": self.log_index, - } - - return encode_canonical(payload).encode() # type: ignore - @dataclass(frozen=True) class RekorLogInfo: @@ -217,43 +125,6 @@ def from_response(cls, dict_: Dict[str, Any]) -> RekorLogInfo: ) -class RekorInclusionProof(BaseModel): - """ - Represents an inclusion proof for a Rekor log entry. - """ - - log_index: StrictInt = Field(..., alias="logIndex") - root_hash: StrictStr = Field(..., alias="rootHash") - tree_size: StrictInt = Field(..., alias="treeSize") - hashes: List[StrictStr] = Field(..., alias="hashes") - - class Config: - allow_population_by_field_name = True - - @validator("log_index") - def _log_index_positive(cls, v: int) -> int: - if v < 0: - raise ValueError(f"Inclusion proof has invalid log index: {v} < 0") - return v - - @validator("tree_size") - def _tree_size_positive(cls, v: int) -> int: - if v < 0: - raise ValueError(f"Inclusion proof has invalid tree size: {v} < 0") - return v - - @validator("tree_size") - def _log_index_within_tree_size( - cls, v: int, values: Dict[str, Any], **kwargs: Any - ) -> int: - if "log_index" in values and v <= values["log_index"]: - raise ValueError( - "Inclusion proof has log index greater than or equal to tree size: " - f"{v} <= {values['log_index']}" - ) - return v - - class RekorClientError(Exception): """ A generic error in the Rekor client. @@ -320,7 +191,7 @@ def get( resp.raise_for_status() except requests.HTTPError as http_error: raise RekorClientError from http_error - return RekorEntry.from_response(resp.json()) + return RekorEntry._from_response(resp.json()) def post( self, @@ -352,7 +223,7 @@ def post( except requests.HTTPError as http_error: raise RekorClientError from http_error - return RekorEntry.from_response(resp.json()) + return RekorEntry._from_response(resp.json()) @property def retrieve(self) -> RekorEntriesRetrieve: @@ -421,7 +292,7 @@ def post( # newer duplicate entries. oldest_entry: Optional[RekorEntry] = None for result in results: - entry = RekorEntry.from_response(result) + entry = RekorEntry._from_response(result) if ( oldest_entry is None or entry.integrated_time < oldest_entry.integrated_time diff --git a/sigstore/_internal/set.py b/sigstore/_internal/set.py index 5a985ebfd..c71d88417 100644 --- a/sigstore/_internal/set.py +++ b/sigstore/_internal/set.py @@ -22,7 +22,8 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes -from sigstore._internal.rekor import RekorClient, RekorEntry +from sigstore._internal.rekor import RekorClient +from sigstore.rekor import RekorEntry class InvalidSetError(Exception): diff --git a/sigstore/oidc.py b/sigstore/oidc.py new file mode 100644 index 000000000..26cbd5886 --- /dev/null +++ b/sigstore/oidc.py @@ -0,0 +1,210 @@ +# Copyright 2022 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. + +""" +API for retrieving OIDC tokens. +""" + +from __future__ import annotations + +import logging +import os +import time +import urllib.parse +import webbrowser +from typing import Callable, List, Optional + +import requests + +DEFAULT_OAUTH_ISSUER_URL = "https://oauth2.sigstore.dev/auth" +STAGING_OAUTH_ISSUER_URL = "https://oauth2.sigstage.dev/auth" + + +class IssuerError(Exception): + """ + Raised on any communication or format error with an OIDC issuer. + """ + + pass + + +class Issuer: + """ + Represents an OIDC issuer (IdP). + """ + + def __init__(self, base_url: str) -> None: + """ + Create a new `Issuer` from the given base URL. + + This URL is used to locate an OpenID Connect configuration file, + which is then used to bootstrap the issuer's state (such + as authorization and token endpoints). + """ + oidc_config_url = urllib.parse.urljoin( + f"{base_url}/", ".well-known/openid-configuration" + ) + + resp: requests.Response = requests.get(oidc_config_url) + try: + resp.raise_for_status() + except requests.HTTPError as http_error: + raise IssuerError from http_error + + struct = resp.json() + + try: + self.auth_endpoint: str = struct["authorization_endpoint"] + except KeyError as key_error: + raise IssuerError( + f"OIDC configuration does not contain authorization endpoint: {struct}" + ) from key_error + + try: + self.token_endpoint: str = struct["token_endpoint"] + except KeyError as key_error: + raise IssuerError( + f"OIDC configuration does not contain token endpoint: {struct}" + ) from key_error + + @classmethod + def production(cls) -> Issuer: + """ + Returns an `Issuer` configured against Sigstore's production-level services. + """ + return cls(DEFAULT_OAUTH_ISSUER_URL) + + @classmethod + def staging(cls) -> Issuer: + """ + Returns an `Issuer` configured against Sigstore's staging-level services. + """ + return cls(STAGING_OAUTH_ISSUER_URL) + + def identity_token( # nosec: B107 + self, client_id: str = "sigstore", client_secret: str = "" + ) -> str: + """ + Retrieves and returns an OpenID Connect token from the current `Issuer`, via OAuth. + + This function blocks on user interaction, either via a web browser or an out-of-band + OAuth flow. + """ + + # This function and the components that it relies on are based off of: + # https://github.com/psteniusubi/python-sample + + from sigstore._internal.oidc.oauth import _OAuthFlow + + force_oob = os.getenv("SIGSTORE_OAUTH_FORCE_OOB") is not None + + code: str + with _OAuthFlow(client_id, client_secret, self) as server: + # Launch web browser + if not force_oob and webbrowser.open(server.base_uri): + print("Waiting for browser interaction...") + else: + server.enable_oob() + print( + f"Go to the following link in a browser:\n\n\t{server.auth_endpoint}" + ) + + if not server.is_oob(): + # Wait until the redirect server populates the response + while server.auth_response is None: + time.sleep(0.1) + + auth_error = server.auth_response.get("error") + if auth_error is not None: + raise IdentityError( + f"Error response from auth endpoint: {auth_error[0]}" + ) + code = server.auth_response["code"][0] + else: + # In the out-of-band case, we wait until the user provides the code + code = input("Enter verification code: ") + + # Provide code to token endpoint + data = { + "grant_type": "authorization_code", + "redirect_uri": server.redirect_uri, + "code": code, + "code_verifier": server.oauth_session.code_verifier, + } + auth = ( + client_id, + client_secret, + ) + logging.debug(f"PAYLOAD: data={data}") + resp: requests.Response = requests.post( + self.token_endpoint, + data=data, + auth=auth, + ) + + try: + resp.raise_for_status() + except requests.HTTPError as http_error: + raise IdentityError from http_error + + token_json = resp.json() + token_error = token_json.get("error") + if token_error is not None: + raise IdentityError(f"Error response from token endpoint: {token_error}") + + return str(token_json["access_token"]) + + +class IdentityError(Exception): + """ + Raised on any OIDC token format or claim error. + """ + + pass + + +class AmbientCredentialError(IdentityError): + """ + Raised when an ambient credential should be present, but + can't be retrieved (e.g. network failure). + """ + + pass + + +class GitHubOidcPermissionCredentialError(AmbientCredentialError): + """ + Raised when the current GitHub Actions environment doesn't have permission + to retrieve an OIDC token. + """ + + pass + + +def detect_credential() -> Optional[str]: + """ + Try each ambient credential detector, returning the first one to succeed + or `None` if all fail. + + Raises `AmbientCredentialError` if any detector fails internally (i.e. + detects a credential, but cannot retrieve it). + """ + from sigstore._internal.oidc.ambient import detect_gcp, detect_github + + detectors: List[Callable[..., Optional[str]]] = [detect_github, detect_gcp] + for detector in detectors: + credential = detector() + if credential is not None: + return credential + return None diff --git a/sigstore/rekor.py b/sigstore/rekor.py new file mode 100644 index 000000000..0c7fd4f87 --- /dev/null +++ b/sigstore/rekor.py @@ -0,0 +1,155 @@ +# Copyright 2022 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. + +""" +Data structures returned by Rekor. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, StrictInt, StrictStr, validator +from securesystemslib.formats import encode_canonical + + +@dataclass(frozen=True) +class RekorEntry: + """ + Represents a Rekor log entry. + + Log entries are retrieved from Rekor after signing or verification events, + or generated from "offline" Rekor bundles supplied by the user. + """ + + uuid: Optional[str] + """ + This entry's unique ID in the Rekor instance it was retrieved from. + + For sharded Rekor deployments, IDs are unique per-shard. + + Not present for `RekorEntry` instances loaded from offline bundles. + """ + + body: str + """ + The base64-encoded body of the Rekor entry. + """ + + integrated_time: int + """ + The UNIX time at which this entry was integrated into the Rekor log. + """ + + log_id: str + """ + The log's ID (as the SHA256 hash of the DER-encoded public key for the log + at the time of entry inclusion). + """ + + log_index: int + """ + The index of this entry within the log. + """ + + inclusion_proof: Optional["RekorInclusionProof"] + """ + An optional inclusion proof for this log entry. + + Only present for entries retrieved from online logs. + """ + + signed_entry_timestamp: str + """ + The base64-encoded Signed Entry Timestamp (SET) for this log entry. + """ + + @classmethod + def _from_response(cls, dict_: dict[str, Any]) -> RekorEntry: + """ + Create a new `RekorEntry` from the given API response. + """ + + # Assumes we only get one entry back + entries = list(dict_.items()) + if len(entries) != 1: + raise ValueError("Received multiple entries in response") + + uuid, entry = entries[0] + + return RekorEntry( + uuid=uuid, + body=entry["body"], + integrated_time=entry["integratedTime"], + log_id=entry["logID"], + log_index=entry["logIndex"], + inclusion_proof=RekorInclusionProof.parse_obj( + entry["verification"]["inclusionProof"] + ), + signed_entry_timestamp=entry["verification"]["signedEntryTimestamp"], + ) + + def encode_canonical(self) -> bytes: + """ + Returns a canonicalized JSON (RFC 8785) representation of the Rekor log entry. + + This encoded representation is suitable for verification against + the Signed Entry Timestamp. + """ + payload = { + "body": self.body, + "integratedTime": self.integrated_time, + "logID": self.log_id, + "logIndex": self.log_index, + } + + return encode_canonical(payload).encode() # type: ignore + + +class RekorInclusionProof(BaseModel): + """ + Represents an inclusion proof for a Rekor log entry. + """ + + log_index: StrictInt = Field(..., alias="logIndex") + root_hash: StrictStr = Field(..., alias="rootHash") + tree_size: StrictInt = Field(..., alias="treeSize") + hashes: List[StrictStr] = Field(..., alias="hashes") + + class Config: + allow_population_by_field_name = True + + @validator("log_index") + def _log_index_positive(cls, v: int) -> int: + if v < 0: + raise ValueError(f"Inclusion proof has invalid log index: {v} < 0") + return v + + @validator("tree_size") + def _tree_size_positive(cls, v: int) -> int: + if v < 0: + raise ValueError(f"Inclusion proof has invalid tree size: {v} < 0") + return v + + @validator("tree_size") + def _log_index_within_tree_size( + cls, v: int, values: Dict[str, Any], **kwargs: Any + ) -> int: + if "log_index" in values and v <= values["log_index"]: + raise ValueError( + "Inclusion proof has log index greater than or equal to tree size: " + f"{v} <= {values['log_index']}" + ) + return v diff --git a/sigstore/_sign.py b/sigstore/sign.py similarity index 97% rename from sigstore/_sign.py rename to sigstore/sign.py index 71e667869..2ea3b2e2e 100644 --- a/sigstore/_sign.py +++ b/sigstore/sign.py @@ -13,7 +13,7 @@ # limitations under the License. """ -Top-level signing APIs for sigstore-python. +API for signing artifacts. """ from __future__ import annotations @@ -31,10 +31,11 @@ from sigstore._internal.fulcio import FulcioClient from sigstore._internal.oidc import Identity -from sigstore._internal.rekor.client import RekorClient, RekorEntry +from sigstore._internal.rekor.client import RekorClient from sigstore._internal.sct import verify_sct from sigstore._internal.tuf import TrustUpdater from sigstore._utils import sha256_streaming +from sigstore.rekor import RekorEntry logger = logging.getLogger(__name__) diff --git a/sigstore/_verify/__init__.py b/sigstore/verify/__init__.py similarity index 92% rename from sigstore/_verify/__init__.py rename to sigstore/verify/__init__.py index 00bbb7e61..5bcdb6a2d 100644 --- a/sigstore/_verify/__init__.py +++ b/sigstore/verify/__init__.py @@ -16,13 +16,13 @@ API for verifying artifact signatures. """ -from sigstore._verify.models import ( +from sigstore.verify.models import ( VerificationFailure, VerificationMaterials, VerificationResult, VerificationSuccess, ) -from sigstore._verify.verifier import ( +from sigstore.verify.verifier import ( CertificateVerificationFailure, RekorEntryMissing, Verifier, diff --git a/sigstore/_verify/models.py b/sigstore/verify/models.py similarity index 98% rename from sigstore/_verify/models.py rename to sigstore/verify/models.py index 0ad5727a5..419fd1dc0 100644 --- a/sigstore/_verify/models.py +++ b/sigstore/verify/models.py @@ -27,8 +27,9 @@ from cryptography.x509 import Certificate, load_pem_x509_certificate from pydantic import BaseModel -from sigstore._internal.rekor import RekorClient, RekorEntry +from sigstore._internal.rekor import RekorClient from sigstore._utils import base64_encode_pem_cert, sha256_streaming +from sigstore.rekor import RekorEntry logger = logging.getLogger(__name__) diff --git a/sigstore/_verify/policy.py b/sigstore/verify/policy.py similarity index 99% rename from sigstore/_verify/policy.py rename to sigstore/verify/policy.py index bc63c5514..ad6fc2760 100644 --- a/sigstore/_verify/policy.py +++ b/sigstore/verify/policy.py @@ -39,7 +39,7 @@ UniformResourceIdentifier, ) -from sigstore._verify.models import ( +from sigstore.verify.models import ( VerificationFailure, VerificationResult, VerificationSuccess, diff --git a/sigstore/_verify/verifier.py b/sigstore/verify/verifier.py similarity index 97% rename from sigstore/_verify/verifier.py rename to sigstore/verify/verifier.py index ef934cc26..8704db3cc 100644 --- a/sigstore/_verify/verifier.py +++ b/sigstore/verify/verifier.py @@ -43,15 +43,15 @@ from sigstore._internal.rekor.client import RekorClient from sigstore._internal.set import InvalidSetError, verify_set from sigstore._internal.tuf import TrustUpdater -from sigstore._verify.models import InvalidRekorEntry as InvalidRekorEntryError -from sigstore._verify.models import RekorEntryMissing as RekorEntryMissingError -from sigstore._verify.models import ( +from sigstore.verify.models import InvalidRekorEntry as InvalidRekorEntryError +from sigstore.verify.models import RekorEntryMissing as RekorEntryMissingError +from sigstore.verify.models import ( VerificationFailure, VerificationMaterials, VerificationResult, VerificationSuccess, ) -from sigstore._verify.policy import VerificationPolicy +from sigstore.verify.policy import VerificationPolicy logger = logging.getLogger(__name__) diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 4ec7d0abc..a84ddc1a9 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -23,14 +23,14 @@ from tuf.ngclient import FetcherInterface from sigstore._internal import tuf -from sigstore._internal.oidc.ambient import ( +from sigstore._internal.rekor.client import RekorBundle +from sigstore.oidc import ( AmbientCredentialError, GitHubOidcPermissionCredentialError, detect_credential, ) -from sigstore._internal.rekor.client import RekorBundle -from sigstore._verify import VerificationMaterials -from sigstore._verify.policy import VerificationSuccess +from sigstore.verify import VerificationMaterials +from sigstore.verify.policy import VerificationSuccess _ASSETS = (Path(__file__).parent / "assets").resolve() assert _ASSETS.is_dir() diff --git a/test/unit/internal/oidc/test_ambient.py b/test/unit/internal/oidc/test_ambient.py index f142d99bf..2cf5fdcbc 100644 --- a/test/unit/internal/oidc/test_ambient.py +++ b/test/unit/internal/oidc/test_ambient.py @@ -17,20 +17,21 @@ from requests import HTTPError from sigstore._internal.oidc import ambient +from sigstore.oidc import detect_credential def test_detect_credential_none(monkeypatch): detect_none = pretend.call_recorder(lambda: None) monkeypatch.setattr(ambient, "detect_github", detect_none) monkeypatch.setattr(ambient, "detect_gcp", detect_none) - assert ambient.detect_credential() is None + assert detect_credential() is None def test_detect_credential(monkeypatch): detect_github = pretend.call_recorder(lambda: "fakejwt") monkeypatch.setattr(ambient, "detect_github", detect_github) - assert ambient.detect_credential() == "fakejwt" + assert detect_credential() == "fakejwt" def test_detect_github_bad_env(monkeypatch): diff --git a/test/unit/internal/oidc/test_issuer.py b/test/unit/internal/oidc/test_issuer.py index 9e060ed52..7e1cdf783 100644 --- a/test/unit/internal/oidc/test_issuer.py +++ b/test/unit/internal/oidc/test_issuer.py @@ -14,15 +14,15 @@ import pytest -from sigstore._internal.oidc import issuer +from sigstore.oidc import Issuer, IssuerError @pytest.mark.online def test_fail_init_url(): - with pytest.raises(issuer.IssuerError): - issuer.Issuer("https://google.com") + with pytest.raises(IssuerError): + Issuer("https://google.com") @pytest.mark.online def test_init_url(): - issuer.Issuer("https://accounts.google.com") + Issuer("https://accounts.google.com") diff --git a/test/unit/internal/rekor/test_client.py b/test/unit/internal/rekor/test_client.py index 7ee99dcf5..d1311e3ed 100644 --- a/test/unit/internal/rekor/test_client.py +++ b/test/unit/internal/rekor/test_client.py @@ -18,6 +18,7 @@ from pydantic import ValidationError from sigstore._internal.rekor import client +from sigstore.rekor import RekorInclusionProof class TestRekorBundle: @@ -47,12 +48,12 @@ def test_parses_and_converts_to_log_entry(self, asset): assert entry.signed_entry_timestamp == bundle.signed_entry_timestamp # Round-tripping from RekorBundle -> RekorEntry -> RekorBundle is lossless. - assert entry.to_bundle() == bundle + assert client.RekorBundle.from_entry(entry) == bundle class TestRekorInclusionProof: def test_valid(self): - proof = client.RekorInclusionProof( + proof = RekorInclusionProof( log_index=1, root_hash="abcd", tree_size=2, hashes=[] ) assert proof is not None @@ -61,23 +62,17 @@ def test_negative_log_index(self): with pytest.raises( ValidationError, match="Inclusion proof has invalid log index" ): - client.RekorInclusionProof( - log_index=-1, root_hash="abcd", tree_size=2, hashes=[] - ) + RekorInclusionProof(log_index=-1, root_hash="abcd", tree_size=2, hashes=[]) def test_negative_tree_size(self): with pytest.raises( ValidationError, match="Inclusion proof has invalid tree size" ): - client.RekorInclusionProof( - log_index=1, root_hash="abcd", tree_size=-1, hashes=[] - ) + RekorInclusionProof(log_index=1, root_hash="abcd", tree_size=-1, hashes=[]) def test_log_index_outside_tree_size(self): with pytest.raises( ValidationError, match="Inclusion proof has log index greater than or equal to tree size", ): - client.RekorInclusionProof( - log_index=2, root_hash="abcd", tree_size=1, hashes=[] - ) + RekorInclusionProof(log_index=2, root_hash="abcd", tree_size=1, hashes=[]) diff --git a/test/unit/test_sign.py b/test/unit/test_sign.py index 44dab8a8a..b5813d2d1 100644 --- a/test/unit/test_sign.py +++ b/test/unit/test_sign.py @@ -17,8 +17,8 @@ import pytest -from sigstore._internal.oidc.ambient import detect_credential -from sigstore._sign import Signer +from sigstore.oidc import detect_credential +from sigstore.sign import Signer @pytest.mark.online diff --git a/test/unit/verify/test_models.py b/test/unit/verify/test_models.py index f51783338..3c6f9b1a9 100644 --- a/test/unit/verify/test_models.py +++ b/test/unit/verify/test_models.py @@ -17,7 +17,7 @@ from sigstore._internal.rekor.client import RekorClient from sigstore._internal.tuf import TrustUpdater -from sigstore._verify.models import InvalidRekorEntry +from sigstore.verify.models import InvalidRekorEntry class TestVerificationMaterials: diff --git a/test/unit/verify/test_policy.py b/test/unit/verify/test_policy.py index b70e14691..8f908b7e2 100644 --- a/test/unit/verify/test_policy.py +++ b/test/unit/verify/test_policy.py @@ -16,8 +16,8 @@ import pytest from cryptography.x509 import ExtensionNotFound -from sigstore._verify import policy -from sigstore._verify.models import VerificationFailure, VerificationSuccess +from sigstore.verify import policy +from sigstore.verify.models import VerificationFailure, VerificationSuccess class TestVerificationPolicy: diff --git a/test/unit/verify/test_verifier.py b/test/unit/verify/test_verifier.py index c04c59e59..0bd2bc625 100644 --- a/test/unit/verify/test_verifier.py +++ b/test/unit/verify/test_verifier.py @@ -14,9 +14,9 @@ import pytest -from sigstore._verify import policy -from sigstore._verify.models import VerificationFailure, VerificationSuccess -from sigstore._verify.verifier import CertificateVerificationFailure, Verifier +from sigstore.verify import policy +from sigstore.verify.models import VerificationFailure, VerificationSuccess +from sigstore.verify.verifier import CertificateVerificationFailure, Verifier @pytest.mark.online