Skip to content

Commit

Permalink
Support configurable OIDC issuers (#62)
Browse files Browse the repository at this point in the history
* Support configurable OIDC issuers

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

* Update README help text

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

* cli: use more descriptive metavars

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

* Stop binding the OIDC configuration to self

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

* cli: fix merge

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

* cli, README: more `--help` descriptions

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

* cli, README: add `--help` text for `--ctfe`

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

* cli, sign, oidc: clarifying comments, relax issuer

Use the `sub` claim for the proof of possession if we don't recognize
the issuer's URL.

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

Co-authored-by: William Woodruff <[email protected]>
  • Loading branch information
tetsuo-cpp and woodruffw authored May 5, 2022
1 parent 2178a1c commit 6117638
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 37 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 42 additions & 2 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,7 @@ def main():
@click.option(
"identity_token",
"--identity-token",
metavar="TOKEN",
type=click.STRING,
help="the OIDC identity token to use",
)
Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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
Expand Down
46 changes: 26 additions & 20 deletions sigstore/_internal/oidc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
54 changes: 54 additions & 0 deletions sigstore/_internal/oidc/issuer.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 18 additions & 13 deletions sigstore/_internal/oidc/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<html>
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
)
Expand Down
7 changes: 7 additions & 0 deletions sigstore/_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
)
Expand Down

0 comments on commit 6117638

Please sign in to comment.