diff --git a/README.md b/README.md index a8ab73f23..3e406e118 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,12 @@ Signing: Usage: sigstore sign [OPTIONS] FILE [FILE ...] Options: - --identity-token TEXT the OIDC identity token to use - --ctfe FILENAME + --identity-token TOKEN the OIDC identity token to use + --ctfe FILENAME A PEM-encoded public key for the CT log + --oidc-client-id ID The custom OpenID Connect client ID to use + --oidc-client-secret SECRET The custom OpenID Connect client secret to + use + --oidc-issuer URL The custom OpenID Connect issuer to use --oidc-disable-ambient-providers Disable ambient OIDC detection (e.g. on GitHub Actions) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index d8917e301..4dd6d933f 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -18,6 +18,7 @@ import click from sigstore._internal.oidc.ambient import detect_credential +from sigstore._internal.oidc.issuer import Issuer from sigstore._internal.oidc.oauth import get_identity_token from sigstore._sign import sign from sigstore._verify import verify @@ -34,6 +35,7 @@ def main(): @click.option( "identity_token", "--identity-token", + metavar="TOKEN", type=click.STRING, help="the OIDC identity token to use", ) @@ -42,6 +44,31 @@ def main(): "--ctfe", type=click.File("rb"), default=resources.open_binary("sigstore._store", "ctfe.pub"), + help="A PEM-encoded public key for the CT log", +) +@click.option( + "oidc_client_id", + "--oidc-client-id", + metavar="ID", + type=click.STRING, + default="sigstore", + help="The custom OpenID Connect client ID to use", +) +@click.option( + "oidc_client_secret", + "--oidc-client-secret", + metavar="SECRET", + type=click.STRING, + default=str(), + help="The custom OpenID Connect client secret to use", +) +@click.option( + "oidc_issuer", + "--oidc-issuer", + metavar="URL", + type=click.STRING, + default="https://oauth2.sigstore.dev/auth", + help="The custom OpenID Connect issuer to use", ) @click.option( "oidc_disable_ambient_providers", @@ -57,7 +84,15 @@ def main(): nargs=-1, required=True, ) -def _sign(files, identity_token, ctfe_pem, oidc_disable_ambient_providers): +def _sign( + files, + identity_token, + ctfe_pem, + oidc_client_id, + oidc_client_secret, + oidc_issuer, + oidc_disable_ambient_providers, +): # The order of precedence is as follows: # # 1) Explicitly supplied identity token @@ -66,7 +101,12 @@ def _sign(files, identity_token, ctfe_pem, oidc_disable_ambient_providers): if not identity_token and not oidc_disable_ambient_providers: identity_token = detect_credential() if not identity_token: - identity_token = get_identity_token() + issuer = Issuer(oidc_issuer) + identity_token = get_identity_token( + oidc_client_id, + oidc_client_secret, + issuer, + ) if not identity_token: click.echo("No identity token supplied or detected!", err=True) raise click.Abort diff --git a/sigstore/_internal/oidc/__init__.py b/sigstore/_internal/oidc/__init__.py index a1a04c41e..8a02162a1 100644 --- a/sigstore/_internal/oidc/__init__.py +++ b/sigstore/_internal/oidc/__init__.py @@ -14,13 +14,13 @@ import jwt -# From https://github.com/sigstore/fulcio/blob/b2186c0/pkg/config/config.go#L182-L201 -OIDC_ISSUERS = { +# See: https://github.com/sigstore/fulcio/blob/b2186c0/pkg/config/config.go#L182-L201 +_KNOWN_OIDC_ISSUERS = { "https://accounts.google.com": "email", "https://oauth2.sigstore.dev/auth": "email", "https://token.actions.githubusercontent.com": "sub", } -AUDIENCE = "sigstore" +_AUDIENCE = "sigstore" class IdentityError(Exception): @@ -31,26 +31,32 @@ class Identity: def __init__(self, identity_token: str) -> None: identity_jwt = jwt.decode(identity_token, options={"verify_signature": False}) - if "iss" not in identity_jwt: - raise IdentityError("Identity token missing the required 'iss' claim") - iss = identity_jwt.get("iss") - - if iss not in OIDC_ISSUERS: - raise IdentityError(f"Not a valid OIDC issuer: {iss!r}") + if iss is None: + raise IdentityError("Identity token missing the required `iss` claim") if "aud" not in identity_jwt: - raise IdentityError("Identity token missing the required 'aud' claim") + raise IdentityError("Identity token missing the required `aud` claim") aud = identity_jwt.get("aud") - if aud != AUDIENCE: - raise IdentityError(f"Audience should be {AUDIENCE!r}, not {aud!r}") - - proof_claim = OIDC_ISSUERS[iss] - if proof_claim not in identity_jwt: - raise IdentityError( - f"Identity token missing the required {proof_claim!r} claim" - ) - - self.proof: str = str(identity_jwt.get(proof_claim)) + if aud != _AUDIENCE: + raise IdentityError(f"Audience should be {_AUDIENCE!r}, not {aud!r}") + + # When verifying the private key possession proof, Fulcio uses + # different claims depending on the token's issuer. + # We currently special-case a handful of these, and fall back + # on signing the "sub" claim otherwise. + proof_claim = _KNOWN_OIDC_ISSUERS.get(iss) + if proof_claim is not None: + if proof_claim not in identity_jwt: + raise IdentityError( + f"Identity token missing the required `{proof_claim!r}` claim" + ) + + self.proof = str(identity_jwt.get(proof_claim)) + else: + try: + self.proof = str(identity_jwt["sub"]) + except KeyError: + raise IdentityError("Identity token missing `sub` claim") diff --git a/sigstore/_internal/oidc/issuer.py b/sigstore/_internal/oidc/issuer.py new file mode 100644 index 000000000..a036ccc70 --- /dev/null +++ b/sigstore/_internal/oidc/issuer.py @@ -0,0 +1,54 @@ +# 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): + pass + + +class Issuer: + def __init__(self, base_url: str) -> None: + 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 94b25a75b..dac3d7f35 100644 --- a/sigstore/_internal/oidc/oauth.py +++ b/sigstore/_internal/oidc/oauth.py @@ -21,11 +21,12 @@ import urllib.parse import uuid import webbrowser -from typing import Dict, cast +from typing import Dict, List, Optional, cast import requests from sigstore._internal.oidc import IdentityError +from sigstore._internal.oidc.issuer import Issuer AUTH_SUCCESS_HTML = """ @@ -68,13 +69,16 @@ def do_GET(self): class RedirectServer(http.server.HTTPServer): - def __init__(self): + def __init__(self, client_id: str, client_secret: str, issuer: Issuer) -> None: super().__init__(("127.0.0.1", 0), RedirectHandler) - self.state = None - self.nonce = None - self.auth_response = None - self._port: int = self.socket.getsockname()[1] + self.state: Optional[str] = None + self.nonce: Optional[str] = None + self.auth_response: Optional[Dict[str, List[str]]] = None self.is_oob = False + self._port: int = self.socket.getsockname()[1] + self._client_id = client_id + self._client_secret = client_secret + self._issuer = issuer @property def active(self) -> bool: @@ -108,7 +112,8 @@ def auth_request_params(self) -> Dict[str, str]: self.nonce = str(uuid.uuid4()) return { "response_type": "code", - "client_id": "sigstore", + "client_id": self._client_id, + "client_secret": self._client_secret, "scope": "openid email", "redirect_uri": self.redirect_uri, "code_challenge": code_challenge.decode("utf-8"), @@ -119,13 +124,13 @@ def auth_request_params(self) -> Dict[str, str]: def auth_request(self) -> str: params = self.auth_request_params() - return "https://oauth2.sigstore.dev/auth/auth?" + urllib.parse.urlencode(params) + return f"{self._issuer.auth_endpoint}?{urllib.parse.urlencode(params)}" def enable_oob(self) -> None: self.is_oob = True -def get_identity_token() -> str: +def get_identity_token(client_id: str, client_secret: str, issuer: Issuer) -> str: """ Retrieve an OpenID Connect token from the Sigstore provider @@ -134,7 +139,7 @@ def get_identity_token() -> str: """ code: str - with RedirectServer() as server: + with RedirectServer(client_id, client_secret, issuer) as server: # Launch web browser if webbrowser.open(server.base_uri): print(f"Your browser will now be opened to:\n{server.auth_request()}\n") @@ -175,11 +180,11 @@ def get_identity_token() -> str: "code_verifier": server.code_verifier.decode("utf-8"), } auth = ( - "sigstore", - "", # Client secret + client_id, + client_secret, ) resp: requests.Response = requests.post( - "https://oauth2.sigstore.dev/auth/token", + issuer.token_endpoint, data=data, auth=auth, ) diff --git a/sigstore/_sign.py b/sigstore/_sign.py index 7636818d8..3161f2163 100644 --- a/sigstore/_sign.py +++ b/sigstore/_sign.py @@ -87,6 +87,13 @@ def sign(file: BinaryIO, identity_token: str, ctfe_pem: bytes) -> SigningResult: # ) # certificate_request = builder.sign(private_key, hashes.SHA256()) + # NOTE: The proof here is not a proof of identity, but solely + # a proof that we possess the private key. Fulcio verifies this + # signature using the public key we give it and the "subject", + # which is a function of one or more claims in the identity token. + # An identity token whose issuer is unknown to Fulcio will fail this + # test, since Fulcio won't know how to construct the subject. + # See: https://github.com/sigstore/fulcio/blob/f6016fd/pkg/challenges/challenges.go#L437-L478 signed_proof = private_key.sign( oidc_identity.proof.encode(), ec.ECDSA(hashes.SHA256()) )