diff --git a/README.md b/README.md index 2adc4c3a..127277fb 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Options: ``` -Verifying +Verifying: ``` @@ -83,6 +83,18 @@ Options: ``` +### Ambient credential detection + +For environments that support OIDC natively, `sigstore` supports automatic ambient credential detection: + +- GitHub: + - Actions: requires setting the `id-token` permission, see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect. An example is [here](https://github.com/sigstore/sigstore-python/blob/main/.github/workflows/release.yml). +- Google Cloud: + - Compute Engine: automatic + - Cloud Build: requires setting `GOOGLE_SERVICE_ACCOUNT_NAME` to an appropriately configured service account name, see https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-direct. An example is [here](https://github.com/sigstore/sigstore-python/blob/main/cloudbuild.yaml) +- GitLab: planned, see https://github.com/sigstore/sigstore-python/issues/31 +- CircleCI: planned, see https://github.com/sigstore/sigstore-python/issues/31 + ## Licensing `sigstore` is licensed under the Apache 2.0 License. diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 00000000..6335b31e --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,12 @@ +steps: + # Install dependencies + - name: python + entrypoint: python + args: ["-m", "pip", "install", ".", "--user"] + + # Sign with ambient GCP credentials + - name: python + entrypoint: python + args: ["-m", "sigstore", "sign", "README.md"] + env: + - "GOOGLE_SERVICE_ACCOUNT_NAME=sigstore-python-test@projectsigstore.iam.gserviceaccount.com" diff --git a/sigstore/_internal/oidc/ambient.py b/sigstore/_internal/oidc/ambient.py index fe59335b..c55b8d5c 100644 --- a/sigstore/_internal/oidc/ambient.py +++ b/sigstore/_internal/oidc/ambient.py @@ -28,7 +28,9 @@ logger = logging.getLogger(__name__) GCP_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name" -GCP_ID_TOKEN_REQUEST_URL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" # noqa # nosec B105 +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_GENERATEIDTOKEN_REQUEST_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateIdToken" # noqa class AmbientCredentialError(IdentityError): @@ -107,31 +109,83 @@ def detect_github() -> Optional[str]: def detect_gcp() -> Optional[str]: logger.debug("GCP: looking for OIDC credentials") - try: - with open(GCP_PRODUCT_NAME_FILE) as f: - name = f.read().strip() - except OSError: - logger.debug("GCP: environment doesn't have GCP product name file; giving up") - return None - if name not in {"Google", "Google Compute Engine"}: - raise AmbientCredentialError( - f"GCP: product name file exists, but product name is {name!r}; giving up" + service_account_name = os.getenv("GOOGLE_SERVICE_ACCOUNT_NAME") + if service_account_name: + logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation") + + logger.debug("GCP: requesting access token") + resp = requests.get( + GCP_TOKEN_REQUEST_URL, + params={"scopes": "https://www.googleapis.com/auth/cloud-platform"}, + headers={"Metadata-Flavor": "Google"}, + ) + try: + resp.raise_for_status() + except requests.HTTPError as http_error: + raise AmbientCredentialError( + f"GCP: access token request failed (code={resp.status_code})" + ) from http_error + + access_token = resp.json().get("access_token") + + if not access_token: + raise AmbientCredentialError("GCP: access token missing from response") + + resp = requests.post( + GCP_GENERATEIDTOKEN_REQUEST_URL.format(service_account_name), + json={"audience": "sigstore", "includeEmail": True}, + headers={ + "Authorization": f"Bearer {access_token}", + }, ) - logger.debug("GCP: requesting OIDC token") - resp = requests.get( - GCP_ID_TOKEN_REQUEST_URL, - params={"audience": DEFAULT_AUDIENCE, "format": "full"}, - headers={"Metadata-Flavor": "Google"}, - ) + logger.debug("GCP: requesting OIDC token") + try: + resp.raise_for_status() + except requests.HTTPError as http_error: + raise AmbientCredentialError( + f"GCP: OIDC token request failed (code={resp.status_code})" + ) from http_error + + oidc_token: str = resp.json().get("token") + + if not oidc_token: + raise AmbientCredentialError("GCP: OIDC token missing from response") + + logger.debug("GCP: successfully requested OIDC token") + return oidc_token + + else: + logger.debug("GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation") + + try: + with open(GCP_PRODUCT_NAME_FILE) as f: + name = f.read().strip() + except OSError: + logger.debug( + "GCP: environment doesn't have GCP product name file; giving up" + ) + return None + + if name not in {"Google", "Google Compute Engine"}: + raise AmbientCredentialError( + f"GCP: product name file exists, but product name is {name!r}; giving up" + ) + + logger.debug("GCP: requesting OIDC token") + resp = requests.get( + GCP_IDENTITY_REQUEST_URL, + params={"audience": DEFAULT_AUDIENCE, "format": "full"}, + headers={"Metadata-Flavor": "Google"}, + ) - try: - resp.raise_for_status() - except requests.HTTPError as http_error: - raise AmbientCredentialError( - f"GCP: OIDC token request failed (code={resp.status_code})" - ) from http_error + try: + resp.raise_for_status() + except requests.HTTPError as http_error: + raise AmbientCredentialError( + f"GCP: OIDC token request failed (code={resp.status_code})" + ) from http_error - logger.debug("GCP: successfully requested OIDC token") - return resp.text + logger.debug("GCP: successfully requested OIDC token") + return resp.text diff --git a/test/internal/oidc/test_ambient.py b/test/internal/oidc/test_ambient.py index 8e28d677..a91fa74c 100644 --- a/test/internal/oidc/test_ambient.py +++ b/test/internal/oidc/test_ambient.py @@ -139,6 +139,162 @@ def test_detect_github(monkeypatch): assert resp.json.calls == [pretend.call()] +def test_gcp_impersonation_access_token_request_fail(monkeypatch): + monkeypatch.setenv( + "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" + ) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + resp = pretend.stub(raise_for_status=pretend.raiser(HTTPError), status_code=999) + requests = pretend.stub( + get=pretend.call_recorder(lambda url, **kw: resp), HTTPError=HTTPError + ) + monkeypatch.setattr(ambient, "requests", requests) + + with pytest.raises( + ambient.AmbientCredentialError, + match=r"GCP: access token request failed \(code=999\)", + ): + ambient.detect_gcp() + + assert logger.debug.calls == [ + pretend.call("GCP: looking for OIDC credentials"), + pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), + pretend.call("GCP: requesting access token"), + ] + + +def test_gcp_impersonation_access_token_missing(monkeypatch): + monkeypatch.setenv( + "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" + ) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {}) + requests = pretend.stub(get=pretend.call_recorder(lambda url, **kw: resp)) + monkeypatch.setattr(ambient, "requests", requests) + + with pytest.raises( + ambient.AmbientCredentialError, + match=r"GCP: access token missing from response", + ): + ambient.detect_gcp() + + assert logger.debug.calls == [ + pretend.call("GCP: looking for OIDC credentials"), + pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), + pretend.call("GCP: requesting access token"), + ] + + +def test_gcp_impersonation_identity_token_request_fail(monkeypatch): + monkeypatch.setenv( + "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" + ) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + access_token = pretend.stub() + get_resp = pretend.stub( + raise_for_status=lambda: None, json=lambda: {"access_token": access_token} + ) + post_resp = pretend.stub( + raise_for_status=pretend.raiser(HTTPError), status_code=999 + ) + requests = pretend.stub( + get=pretend.call_recorder(lambda url, **kw: get_resp), + post=pretend.call_recorder(lambda url, **kw: post_resp), + HTTPError=HTTPError, + ) + monkeypatch.setattr(ambient, "requests", requests) + + with pytest.raises( + ambient.AmbientCredentialError, + match=r"GCP: OIDC token request failed \(code=999\)", + ): + ambient.detect_gcp() + + assert logger.debug.calls == [ + pretend.call("GCP: looking for OIDC credentials"), + pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), + pretend.call("GCP: requesting access token"), + pretend.call("GCP: requesting OIDC token"), + ] + + +def test_gcp_impersonation_identity_token_missing(monkeypatch): + monkeypatch.setenv( + "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" + ) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + access_token = pretend.stub() + get_resp = pretend.stub( + raise_for_status=lambda: None, json=lambda: {"access_token": access_token} + ) + post_resp = pretend.stub(raise_for_status=lambda: None, json=lambda: {}) + requests = pretend.stub( + get=pretend.call_recorder(lambda url, **kw: get_resp), + post=pretend.call_recorder(lambda url, **kw: post_resp), + HTTPError=HTTPError, + ) + monkeypatch.setattr(ambient, "requests", requests) + + with pytest.raises( + ambient.AmbientCredentialError, + match=r"GCP: OIDC token missing from response", + ): + ambient.detect_gcp() + + assert logger.debug.calls == [ + pretend.call("GCP: looking for OIDC credentials"), + pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), + pretend.call("GCP: requesting access token"), + pretend.call("GCP: requesting OIDC token"), + ] + + +def test_gcp_impersonation_succeeds(monkeypatch): + monkeypatch.setenv( + "GOOGLE_SERVICE_ACCOUNT_NAME", "identity@project.iam.gserviceaccount.com" + ) + + logger = pretend.stub(debug=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(ambient, "logger", logger) + + access_token = pretend.stub() + oidc_token = pretend.stub() + get_resp = pretend.stub( + raise_for_status=lambda: None, json=lambda: {"access_token": access_token} + ) + post_resp = pretend.stub( + raise_for_status=lambda: None, json=lambda: {"token": oidc_token} + ) + requests = pretend.stub( + get=pretend.call_recorder(lambda url, **kw: get_resp), + post=pretend.call_recorder(lambda url, **kw: post_resp), + HTTPError=HTTPError, + ) + monkeypatch.setattr(ambient, "requests", requests) + + assert ambient.detect_gcp() == oidc_token + + assert logger.debug.calls == [ + pretend.call("GCP: looking for OIDC credentials"), + pretend.call("GCP: GOOGLE_SERVICE_ACCOUNT_NAME set; attempting impersonation"), + pretend.call("GCP: requesting access token"), + pretend.call("GCP: requesting OIDC token"), + pretend.call("GCP: successfully requested OIDC token"), + ] + + def test_gcp_bad_env(monkeypatch): oserror = pretend.raiser(OSError) monkeypatch.setitem(ambient.__builtins__, "open", oserror) # type: ignore @@ -149,6 +305,9 @@ def test_gcp_bad_env(monkeypatch): assert ambient.detect_gcp() is None assert logger.debug.calls == [ pretend.call("GCP: looking for OIDC credentials"), + pretend.call( + "GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation" + ), pretend.call("GCP: environment doesn't have GCP product name file; giving up"), ] @@ -171,6 +330,9 @@ def test_gcp_wrong_product(monkeypatch): assert logger.debug.calls == [ pretend.call("GCP: looking for OIDC credentials"), + pretend.call( + "GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation" + ), ] @@ -194,7 +356,7 @@ def test_detect_gcp_request_fails(monkeypatch): ambient.detect_gcp() assert requests.get.calls == [ pretend.call( - ambient.GCP_ID_TOKEN_REQUEST_URL, + ambient.GCP_IDENTITY_REQUEST_URL, params={"audience": "sigstore", "format": "full"}, headers={"Metadata-Flavor": "Google"}, ) @@ -222,13 +384,16 @@ def test_detect_gcp(monkeypatch, product_name): assert ambient.detect_gcp() == "fakejwt" assert requests.get.calls == [ pretend.call( - ambient.GCP_ID_TOKEN_REQUEST_URL, + ambient.GCP_IDENTITY_REQUEST_URL, params={"audience": "sigstore", "format": "full"}, headers={"Metadata-Flavor": "Google"}, ) ] assert logger.debug.calls == [ pretend.call("GCP: looking for OIDC credentials"), + pretend.call( + "GCP: GOOGLE_SERVICE_ACCOUNT_NAME not set; skipping impersonation" + ), pretend.call("GCP: requesting OIDC token"), pretend.call("GCP: successfully requested OIDC token"), ]