Repository: sigstore/fulcio
Pull request #1703 (2024-06-20 22:56:10)

Add Chainguard OIDC provider.
#### Summary

This adds support for Chainguard issued tokens, so that users can sign with their Chainguard-issued identity, and so that we can explore signing our own content with our internal service principal construct (see issue).

Related: https://github.com/sigstore/fulcio/issues/1702

#### Release Note

Add support for signing with Chainguard-issued tokens.

#### Documentation

This is mostly intended for our own use, but I'd be happy to document how folks can make use of this with `chainctl auth token --audience sigstore` if someone points me in the right direction.

haydentherapper: Is this a provider you would want enabled in the public instance as well? If so, do you have an estimated QPS we should expect?
mattmoor: > Is this a provider you would want enabled in the public instance as well? If so, do you have an estimated QPS we should expect?

Yes.  No real estimation, but I would expect that if anything this would cannibalize QPS we are currently sending with github actions tokens.  I could work with someone on the rotation to try and figure out what that might look like, if it's important.
mattmoor: Rebased on @cpanato go change
mattmoor: ... and fixed merge conflict that drops `CompactSerialize()` method.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) Report
Attention: Patch coverage is `78.94737%` with `8 lines` in your changes missing coverage. Please review.
> Project coverage is 48.79%. Comparing base [(`cf238ac`)](https://app.codecov.io/gh/sigstore/fulcio/commit/cf238accd68ef13e1e3425a5d87db2f352de173c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) to head [(`d87bb86`)](https://app.codecov.io/gh/sigstore/fulcio/commit/d87bb86a29d719d1c0b3749ef5c57dbc460ad85e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore).
> Report is 126 commits behind head on main.

| [Files](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) | Patch % | Lines |
|---|---|---|
| [pkg/identity/chainguard/principal.go](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?src=pr&el=tree&filepath=pkg%2Fidentity%2Fchainguard%2Fprincipal.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL2lkZW50aXR5L2NoYWluZ3VhcmQvcHJpbmNpcGFsLmdv) | 77.77% | [3 Missing and 3 partials :warning: ](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |
| [pkg/identity/chainguard/issuer.go](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?src=pr&el=tree&filepath=pkg%2Fidentity%2Fchainguard%2Fissuer.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore#diff-cGtnL2lkZW50aXR5L2NoYWluZ3VhcmQvaXNzdWVyLmdv) | 71.42% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1703      +/-   ##
==========================================
- Coverage   57.93%   48.79%   -9.14%     
==========================================
  Files          50       69      +19     
  Lines        3119     4082     +963     
==========================================
+ Hits         1807     1992     +185     
- Misses       1154     1869     +715     
- Partials      158      221      +63     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/sigstore/fulcio/pull/1703?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sigstore).

mattmoor: I'd say we land this as-is and we can make additive changes as the need for enriching things under our own OID arises. 👍 

Patch:
diff --git a/config/config.jsn b/config/config.jsn
index 8e66be7..27fae54 100644
--- a/config/config.jsn
+++ b/config/config.jsn
@@ -25,6 +25,11 @@
       "IssuerURL": "https://oidc.codefresh.io",
       "ClientID": "sigstore",
       "Type": "codefresh-workflow"
+    },
+    "https://issuer.enforce.dev": {
+      "IssuerURL": "https://issuer.enforce.dev",
+      "ClientID": "sigstore",
+      "Type": "chainguard-identity"
     }
   }
 }
diff --git a/config/fulcio-config.yaml b/config/fulcio-config.yaml
index 44a7107..0f7a0ad 100644
--- a/config/fulcio-config.yaml
+++ b/config/fulcio-config.yaml
@@ -58,6 +58,11 @@ data:
               "ClientID": "sigstore",
               "Type": "gitlab-pipeline"
             },
+            "https://issuer.enforce.dev": {
+              "IssuerURL": "https://issuer.enforce.dev",
+              "ClientID": "sigstore",
+              "Type": "chainguard-identity"
+            },
             "https://oauth2.sigstore.dev/auth": {
               "IssuerURL": "https://oauth2.sigstore.dev/auth",
               "ClientID": "sigstore",
diff --git a/federation/issuer.enforce.dev/config.yaml b/federation/issuer.enforce.dev/config.yaml
new file mode 100644
index 0000000..45e252a
--- /dev/null
+++ b/federation/issuer.enforce.dev/config.yaml
@@ -0,0 +1,19 @@
+# Copyright 2024 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.
+
+url: https://issuer.enforce.dev
+# TODO(mattmoor): Change to a group.
+contact: mattmoor@chainguard.dev
+description: "Chainguard identity tokens"
+type: "chainguard-identity"
diff --git a/go.mod b/go.mod
index 05cf248..b33009c 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.22.4
 
 require (
 	chainguard.dev/go-grpc-kit v0.17.5
+	chainguard.dev/sdk v0.1.20
 	cloud.google.com/go/security v1.17.0
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/ThalesIgnite/crypto11 v1.2.5
@@ -140,7 +141,7 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	goa.design/goa v2.2.5+incompatible // indirect
 	golang.org/x/crypto v0.24.0 // indirect
-	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
+	golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect
 	golang.org/x/net v0.26.0 // indirect
 	golang.org/x/oauth2 v0.21.0 // indirect
 	golang.org/x/sync v0.7.0 // indirect
diff --git a/go.sum b/go.sum
index 71dc3ca..16749b9 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
 chainguard.dev/go-grpc-kit v0.17.5 h1:y0MHgqm3v0LKKQfxPJV57wkXxa8uMSpNTjhtHbNh1DY=
 chainguard.dev/go-grpc-kit v0.17.5/go.mod h1:vQGcwZiX6jXwhyLPCZwVMvjITD+XcrSmQzuCTW/XcVc=
+chainguard.dev/sdk v0.1.20 h1:x46/Nd+DbfvaO4F0NEHUYIkGFa/B3wA+0KKByVNd61I=
+chainguard.dev/sdk v0.1.20/go.mod h1:UO+3bmvsha1UoXxvgNnMze1kfNLuADe2WWi3AirvvxE=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
 cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
@@ -398,8 +400,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
 golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
 golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
+golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 900ecef..6a6aca7 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -279,6 +279,7 @@ const (
 	IssuerTypeGithubWorkflow    = "github-workflow"
 	IssuerTypeCodefreshWorkflow = "codefresh-workflow"
 	IssuerTypeGitLabPipeline    = "gitlab-pipeline"
+	IssuerTypeChainguard        = "chainguard-identity"
 	IssuerTypeKubernetes        = "kubernetes"
 	IssuerTypeSpiffe            = "spiffe"
 	IssuerTypeURI               = "uri"
@@ -517,6 +518,8 @@ func issuerToChallengeClaim(issType IssuerType, challengeClaim string) string {
 		return "sub"
 	case IssuerTypeCodefreshWorkflow:
 		return "sub"
+	case IssuerTypeChainguard:
+		return "sub"
 	case IssuerTypeKubernetes:
 		return "sub"
 	case IssuerTypeSpiffe:
diff --git a/pkg/identity/chainguard/issuer.go b/pkg/identity/chainguard/issuer.go
new file mode 100644
index 0000000..41df8c4
--- /dev/null
+++ b/pkg/identity/chainguard/issuer.go
@@ -0,0 +1,40 @@
+// Copyright 2024 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.
+
+package chainguard
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/sigstore/fulcio/pkg/config"
+	"github.com/sigstore/fulcio/pkg/identity"
+	"github.com/sigstore/fulcio/pkg/identity/base"
+)
+
+type issuer struct {
+	identity.Issuer
+}
+
+func Issuer(issuerURL string) identity.Issuer {
+	return &issuer{base.Issuer(issuerURL)}
+}
+
+func (e *issuer) Authenticate(ctx context.Context, token string, opts ...config.InsecureOIDCConfigOption) (identity.Principal, error) {
+	idtoken, err := identity.Authorize(ctx, token, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("authorizing chainguard issuer: %w", err)
+	}
+	return PrincipalFromIDToken(ctx, idtoken)
+}
diff --git a/pkg/identity/chainguard/principal.go b/pkg/identity/chainguard/principal.go
new file mode 100644
index 0000000..d2940c6
--- /dev/null
+++ b/pkg/identity/chainguard/principal.go
@@ -0,0 +1,80 @@
+// Copyright 2024 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.
+
+package chainguard
+
+import (
+	"context"
+	"crypto/x509"
+	"net/url"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/sigstore/fulcio/pkg/certificate"
+	"github.com/sigstore/fulcio/pkg/identity"
+)
+
+type workflowPrincipal struct {
+	issuer  string
+	subject string
+
+	actor            map[string]string
+	servicePrincipal string
+}
+
+var _ identity.Principal = (*workflowPrincipal)(nil)
+
+func (w workflowPrincipal) Name(_ context.Context) string {
+	return w.subject
+}
+
+func PrincipalFromIDToken(_ context.Context, token *oidc.IDToken) (identity.Principal, error) {
+	var claims struct {
+		Actor    map[string]string `json:"act"`
+		Internal struct {
+			ServicePrincipal string `json:"service-principal,omitempty"`
+		} `json:"internal"`
+	}
+
+	if err := token.Claims(&claims); err != nil {
+		return nil, err
+	}
+
+	return &workflowPrincipal{
+		issuer:           token.Issuer,
+		subject:          token.Subject,
+		actor:            claims.Actor,
+		servicePrincipal: claims.Internal.ServicePrincipal,
+	}, nil
+}
+
+func (w workflowPrincipal) Embed(_ context.Context, cert *x509.Certificate) error {
+	baseURL, err := url.Parse(w.issuer)
+	if err != nil {
+		return err
+	}
+
+	// Set SAN to the <issuer>/<subject>
+	cert.URIs = []*url.URL{baseURL.JoinPath(w.subject)}
+
+	cert.ExtraExtensions, err = certificate.Extensions{
+		Issuer: w.issuer,
+
+		// TODO(mattmoor): Embed more of the Chainguard token structure via OIDs.
+	}.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/pkg/server/issuer_pool.go b/pkg/server/issuer_pool.go
index 9905df5..18349f2 100644
--- a/pkg/server/issuer_pool.go
+++ b/pkg/server/issuer_pool.go
@@ -18,6 +18,7 @@ import (
 	"github.com/sigstore/fulcio/pkg/config"
 	"github.com/sigstore/fulcio/pkg/identity"
 	"github.com/sigstore/fulcio/pkg/identity/buildkite"
+	"github.com/sigstore/fulcio/pkg/identity/chainguard"
 	"github.com/sigstore/fulcio/pkg/identity/codefresh"
 	"github.com/sigstore/fulcio/pkg/identity/email"
 	"github.com/sigstore/fulcio/pkg/identity/github"
@@ -62,6 +63,8 @@ func getIssuer(meta string, i config.OIDCIssuer) identity.Issuer {
 		return buildkite.Issuer(issuerURL)
 	case config.IssuerTypeCodefreshWorkflow:
 		return codefresh.Issuer(issuerURL)
+	case config.IssuerTypeChainguard:
+		return chainguard.Issuer(issuerURL)
 	case config.IssuerTypeKubernetes:
 		return kubernetes.Issuer(issuerURL)
 	case config.IssuerTypeSpiffe:


Test patch:
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 1c926a0..4c09676 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -498,6 +498,9 @@ func Test_issuerToChallengeClaim(t *testing.T) {
 	if claim := issuerToChallengeClaim(IssuerTypeCodefreshWorkflow, ""); claim != "sub" {
 		t.Fatalf("expected sub subject claim for Codefresh issuer, got %s", claim)
 	}
+	if claim := issuerToChallengeClaim(IssuerTypeChainguard, ""); claim != "sub" {
+		t.Fatalf("expected sub subject claim for Chainguard issuer, got %s", claim)
+	}
 	if claim := issuerToChallengeClaim(IssuerTypeKubernetes, ""); claim != "sub" {
 		t.Fatalf("expected sub subject claim for K8S issuer, got %s", claim)
 	}
diff --git a/pkg/identity/chainguard/issuer_test.go b/pkg/identity/chainguard/issuer_test.go
new file mode 100644
index 0000000..6e3412a
--- /dev/null
+++ b/pkg/identity/chainguard/issuer_test.go
@@ -0,0 +1,97 @@
+// Copyright 2024 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.
+
+package chainguard
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"testing"
+	"unsafe"
+
+	"chainguard.dev/sdk/uidp"
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/sigstore/fulcio/pkg/config"
+	"github.com/sigstore/fulcio/pkg/identity"
+)
+
+func TestIssuer(t *testing.T) {
+	ctx := context.Background()
+	url := "test-issuer-url"
+	issuer := Issuer(url)
+
+	// test the Match function
+	t.Run("match", func(t *testing.T) {
+		if matches := issuer.Match(ctx, url); !matches {
+			t.Fatal("expected url to match but it doesn't")
+		}
+		if matches := issuer.Match(ctx, "some-other-url"); matches {
+			t.Fatal("expected match to fail but it didn't")
+		}
+	})
+
+	t.Run("authenticate", func(t *testing.T) {
+		group := uidp.NewUIDP("")
+		id := group.NewChild()
+
+		token := &oidc.IDToken{
+			Issuer:  "https://iss.example.com",
+			Subject: id.String(),
+		}
+		claims, err := json.Marshal(map[string]interface{}{
+			"iss": "https://iss.example.com",
+			"sub": id.String(),
+
+			// Actor claims track the identity that was used to assume the
+			// Chainguard identity.  In this case, it is the Catalog Syncer
+			// service principal.
+			"act": map[string]string{
+				"iss": "https://iss.example.com/",
+				"sub": fmt.Sprintf("catalog-syncer:%s", group.String()),
+				"aud": "chainguard",
+			},
+			"internal": map[string]interface{}{
+				"service-principal": "CATALOG_SYNCER",
+			},
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		withClaims(token, claims)
+
+		identity.Authorize = func(_ context.Context, _ string, _ ...config.InsecureOIDCConfigOption) (*oidc.IDToken, error) {
+			return token, nil
+		}
+		principal, err := issuer.Authenticate(ctx, "token")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if principal.Name(ctx) != id.String() {
+			t.Fatalf("got unexpected name %s", principal.Name(ctx))
+		}
+	})
+}
+
+// reflect hack because "claims" field is unexported by oidc IDToken
+// https://github.com/coreos/go-oidc/pull/329
+func withClaims(token *oidc.IDToken, data []byte) {
+	val := reflect.Indirect(reflect.ValueOf(token))
+	member := val.FieldByName("claims")
+	pointer := unsafe.Pointer(member.UnsafeAddr())
+	realPointer := (*[]byte)(pointer)
+	*realPointer = data
+}
diff --git a/pkg/identity/chainguard/principal_test.go b/pkg/identity/chainguard/principal_test.go
new file mode 100644
index 0000000..db995db
--- /dev/null
+++ b/pkg/identity/chainguard/principal_test.go
@@ -0,0 +1,232 @@
+// Copyright 2024 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.
+
+package chainguard
+
+import (
+	"context"
+	"crypto/x509"
+	"encoding/asn1"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/url"
+	"reflect"
+	"strings"
+	"testing"
+
+	"chainguard.dev/sdk/uidp"
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/sigstore/fulcio/pkg/identity"
+)
+
+func TestJobPrincipalFromIDToken(t *testing.T) {
+	group := uidp.NewUIDP("")
+	id := group.NewChild()
+
+	tests := map[string]struct {
+		Claims          map[string]interface{}
+		ExpectPrincipal workflowPrincipal
+		WantErr         bool
+		ErrContains     string
+	}{
+		`Service principal token`: {
+			Claims: map[string]interface{}{
+				"iss": "https://issuer.enforce.dev",
+				"sub": id.String(),
+				// Actor claims track the identity that was used to assume the
+				// Chainguard identity.  In this case, it is the Catalog Syncer
+				// service principal.
+				"act": map[string]string{
+					"iss": "https://iss.example.com/",
+					"sub": fmt.Sprintf("catalog-syncer:%s", group.String()),
+					"aud": "chainguard",
+				},
+				"internal": map[string]interface{}{
+					"service-principal": "CATALOG_SYNCER",
+				},
+			},
+			ExpectPrincipal: workflowPrincipal{
+				issuer:  "https://issuer.enforce.dev",
+				subject: id.String(),
+				actor: map[string]string{
+					"iss": "https://iss.example.com/",
+					"sub": fmt.Sprintf("catalog-syncer:%s", group.String()),
+					"aud": "chainguard",
+				},
+				servicePrincipal: "CATALOG_SYNCER",
+			},
+			WantErr: false,
+		},
+		`Human SSO token`: {
+			Claims: map[string]interface{}{
+				"iss": "https://issuer.enforce.dev",
+				"sub": group.String(),
+				// Actor claims track the identity that was used to assume the
+				// Chainguard identity.  In this case, it is the Catalog Syncer
+				// service principal.
+				"act": map[string]string{
+					"iss": "https://auth.chainguard.dev/",
+					"sub": "google-oauth2|1234567890",
+					"aud": "fdsaldfkjhasldf",
+				},
+			},
+			ExpectPrincipal: workflowPrincipal{
+				issuer:  "https://issuer.enforce.dev",
+				subject: group.String(),
+				actor: map[string]string{
+					"iss": "https://auth.chainguard.dev/",
+					"sub": "google-oauth2|1234567890",
+					"aud": "fdsaldfkjhasldf",
+				},
+			},
+			WantErr: false,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			token := &oidc.IDToken{
+				Issuer:  test.Claims["iss"].(string),
+				Subject: test.Claims["sub"].(string),
+			}
+			claims, err := json.Marshal(test.Claims)
+			if err != nil {
+				t.Fatal(err)
+			}
+			withClaims(token, claims)
+
+			untyped, err := PrincipalFromIDToken(context.TODO(), token)
+			if err != nil {
+				if !test.WantErr {
+					t.Fatal("didn't expect error", err)
+				}
+				if !strings.Contains(err.Error(), test.ErrContains) {
+					t.Fatalf("expected error %s to contain %s", err, test.ErrContains)
+				}
+				return
+			}
+			if err == nil && test.WantErr {
+				t.Fatal("expected error but got none")
+			}
+
+			principal, ok := untyped.(*workflowPrincipal)
+			if !ok {
+				t.Errorf("Got wrong principal type %v", untyped)
+			}
+			if !reflect.DeepEqual(*principal, test.ExpectPrincipal) {
+				t.Errorf("got %v principal and expected %v", *principal, test.ExpectPrincipal)
+			}
+		})
+	}
+}
+
+func TestEmbed(t *testing.T) {
+	group := uidp.NewUIDP("")
+	id := group.NewChild()
+
+	tests := map[string]struct {
+		Principal identity.Principal
+		WantErr   bool
+		WantFacts map[string]func(x509.Certificate) error
+	}{
+		`Chainguard Service Principal`: {
+			Principal: &workflowPrincipal{
+				issuer:  "https://issuer.enforce.dev",
+				subject: id.String(),
+				actor: map[string]string{
+					"iss": "https://iss.example.com/",
+					"sub": fmt.Sprintf("catalog-syncer:%s", group.String()),
+					"aud": "chainguard",
+				},
+				servicePrincipal: "CATALOG_SYNCER",
+			},
+			WantErr: false,
+			WantFacts: map[string]func(x509.Certificate) error{
+				`Certificate SAN has correct value`:             factSanURIIs(fmt.Sprintf("https://issuer.enforce.dev/%s", id.String())),
+				`Certificate has correct issuer (v2) extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://issuer.enforce.dev"),
+			},
+		},
+		`Chainguard Human SSO`: {
+			Principal: &workflowPrincipal{
+				issuer:  "https://issuer.enforce.dev",
+				subject: group.String(),
+				actor: map[string]string{
+					"iss": "https://auth.chainguard.dev/",
+					"sub": "google-oauth2|1234567890",
+					"aud": "fdsaldfkjhasldf",
+				},
+			},
+			WantErr: false,
+			WantFacts: map[string]func(x509.Certificate) error{
+				`Certificate SAN has correct value`:             factSanURIIs(fmt.Sprintf("https://issuer.enforce.dev/%s", group.String())),
+				`Certificate has correct issuer (v2) extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://issuer.enforce.dev"),
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			var cert x509.Certificate
+			err := test.Principal.Embed(context.TODO(), &cert)
+			if err != nil {
+				if !test.WantErr {
+					t.Error(err)
+				}
+				return
+			} else if test.WantErr {
+				t.Error("expected error")
+			}
+			for factName, fact := range test.WantFacts {
+				t.Run(factName, func(t *testing.T) {
+					if err := fact(cert); err != nil {
+						t.Error(err)
+					}
+				})
+			}
+		})
+	}
+}
+
+func factExtensionIs(oid asn1.ObjectIdentifier, value string) func(x509.Certificate) error {
+	return func(cert x509.Certificate) error {
+		for _, ext := range cert.ExtraExtensions {
+			if ext.Id.Equal(oid) {
+				var strVal string
+				_, _ = asn1.Unmarshal(ext.Value, &strVal)
+				if value != strVal {
+					return fmt.Errorf("expected oid %v to be %s, but got %s", oid, value, strVal)
+				}
+				return nil
+			}
+		}
+		return errors.New("extension not set")
+	}
+}
+
+func factSanURIIs(value string) func(x509.Certificate) error {
+	return func(cert x509.Certificate) error {
+		url, err := url.Parse(value)
+
+		if err != nil {
+			return err
+		}
+
+		if cert.URIs[0].String() != url.String() {
+			return fmt.Errorf("expected SAN o be %s, but got %s", value, cert.URIs[0].String())
+		}
+
+		return nil
+	}
+}
diff --git a/pkg/server/grpc_server_test.go b/pkg/server/grpc_server_test.go
index 06d6c9e..9990831 100644
--- a/pkg/server/grpc_server_test.go
+++ b/pkg/server/grpc_server_test.go
@@ -39,6 +39,7 @@ import (
 	"testing"
 	"time"
 
+	"chainguard.dev/sdk/uidp"
 	"github.com/go-jose/go-jose/v4"
 	"github.com/go-jose/go-jose/v4/jwt"
 	ctclient "github.com/google/certificate-transparency-go/client"
@@ -197,6 +198,7 @@ func TestGetConfiguration(t *testing.T) {
 	_, gitHubIssuer := newOIDCIssuer(t)
 	_, gitLabIssuer := newOIDCIssuer(t)
 	_, codefreshIssuer := newOIDCIssuer(t)
+	_, chainguardIssuer := newOIDCIssuer(t)
 
 	issuerDomain, err := url.Parse(usernameIssuer)
 	if err != nil {
@@ -247,6 +249,11 @@ func TestGetConfiguration(t *testing.T) {
 				"IssuerURL": %q,
 				"ClientID": "sigstore",
 				"Type": "codefresh-workflow"
+			},
+			%q: {
+				"IssuerURL": %q,
+				"ClientID": "sigstore",
+				"Type": "chainguard-identity"
 			}
 		},
 		"MetaIssuers": {
@@ -263,6 +270,7 @@ func TestGetConfiguration(t *testing.T) {
 		gitHubIssuer, gitHubIssuer,
 		gitLabIssuer, gitLabIssuer,
 		codefreshIssuer, codefreshIssuer,
+		chainguardIssuer, chainguardIssuer,
 		k8sIssuer)))
 	if err != nil {
 		t.Fatalf("config.Read() = %v", err)
@@ -283,14 +291,15 @@ func TestGetConfiguration(t *testing.T) {
 		t.Fatal("GetConfiguration failed", err)
 	}
 
-	if len(config.Issuers) != 9 {
-		t.Fatalf("expected 9 issuers, got %v", len(config.Issuers))
+	if got, want := len(config.Issuers), 10; got != want {
+		t.Fatalf("expected %d issuers, got %d", want, got)
 	}
 
 	expectedIssuers := map[string]bool{
 		emailIssuer: true, spiffeIssuer: true, uriIssuer: true,
 		usernameIssuer: true, k8sIssuer: true, gitHubIssuer: true,
 		buildkiteIssuer: true, gitLabIssuer: true, codefreshIssuer: true,
+		chainguardIssuer: true,
 	}
 	for _, iss := range config.Issuers {
 		var issURL string
@@ -1400,6 +1409,130 @@ func TestAPIWithCodefresh(t *testing.T) {
 	}
 }
 
+// chainguardClaims holds the additional JWT claims for Chainguard OIDC tokens
+type chainguardClaims struct {
+	Actor    map[string]string `json:"act"`
+	Internal struct {
+		ServicePrincipal string `json:"service-principal,omitempty"`
+	} `json:"internal"`
+}
+
+// Tests API for Chainguard subject types
+func TestAPIWithChainguard(t *testing.T) {
+	chainguardSigner, chainguardIssuer := newOIDCIssuer(t)
+
+	// Create a FulcioConfig that supports these issuers.
+	cfg, err := config.Read([]byte(fmt.Sprintf(`{
+		"OIDCIssuers": {
+			%q: {
+				"IssuerURL": %q,
+				"ClientID": "sigstore",
+				"Type": "chainguard-identity"
+			}
+        }
+	}`, chainguardIssuer, chainguardIssuer)))
+	if err != nil {
+		t.Fatalf("config.Read() = %v", err)
+	}
+
+	group := uidp.NewUIDP("")
+	chainguardSubject := group.NewChild()
+	claims := chainguardClaims{
+		Actor: map[string]string{
+			"iss": chainguardIssuer,
+			"sub": fmt.Sprintf("catalog-syncer:%s", group.String()),
+			"aud": "chainguard",
+		},
+		Internal: struct {
+			ServicePrincipal string `json:"service-principal,omitempty"`
+		}{
+			ServicePrincipal: "CATALOG_SYNCER",
+		},
+	}
+
+	// Create an OIDC token using this issuer's signer.
+	tok, err := jwt.Signed(chainguardSigner).Claims(jwt.Claims{
+		Issuer:   chainguardIssuer,
+		IssuedAt: jwt.NewNumericDate(time.Now()),
+		Expiry:   jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
+		Subject:  chainguardSubject.String(),
+		Audience: jwt.Audience{"sigstore"},
+	}).Claims(&claims).Serialize()
+	if err != nil {
+		t.Fatalf("CompactSerialize() = %v", err)
+	}
+
+	ctClient, eca := createCA(cfg, t)
+	ctx := context.Background()
+	server, conn := setupGRPCForTest(t, cfg, ctClient, eca)
+	defer func() {
+		server.Stop()
+		conn.Close()
+	}()
+
+	client := protobuf.NewCAClient(conn)
+
+	pubBytes, proof := generateKeyAndProof(chainguardSubject.String(), t)
+
+	// Hit the API to have it sign our certificate.
+	resp, err := client.CreateSigningCertificate(ctx, &protobuf.CreateSigningCertificateRequest{
+		Credentials: &protobuf.Credentials{
+			Credentials: &protobuf.Credentials_OidcIdentityToken{
+				OidcIdentityToken: tok,
+			},
+		},
+		Key: &protobuf.CreateSigningCertificateRequest_PublicKeyRequest{
+			PublicKeyRequest: &protobuf.PublicKeyRequest{
+				PublicKey: &protobuf.PublicKey{
+					Content: pubBytes,
+				},
+				ProofOfPossession: proof,
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("SigningCert() = %v", err)
+	}
+
+	leafCert := verifyResponse(resp, eca, chainguardIssuer, t)
+
+	// Expect URI values
+	if len(leafCert.URIs) != 1 {
+		t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs))
+	}
+	chainguardURL := fmt.Sprintf("%s/%s", chainguardIssuer, chainguardSubject)
+	chainguardURI, err := url.Parse(chainguardURL)
+	if err != nil {
+		t.Fatalf("failed to parse expected url")
+	}
+	if *leafCert.URIs[0] != *chainguardURI {
+		t.Fatalf("URIs do not match: Expected %v, got %v", chainguardURI, leafCert.URIs[0])
+	}
+
+	expectedExts := map[int]string{
+		8: chainguardIssuer,
+
+		// TODO(mattmoor): Embed more of the Chainguard token structure via OIDs.
+	}
+	for o, value := range expectedExts {
+		ext, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, o})
+		if !found {
+			t.Fatalf("expected extension in custom OID 1.3.6.1.4.1.57264.1.%d", o)
+		}
+		var extValue string
+		rest, err := asn1.Unmarshal(ext.Value, &extValue)
+		if err != nil {
+			t.Fatalf("error unmarshalling extension: :%v", err)
+		}
+		if len(rest) != 0 {
+			t.Fatal("error unmarshalling extension, rest is not 0")
+		}
+		if string(extValue) != value {
+			t.Fatalf("unexpected extension value, expected %s, got %s", value, extValue)
+		}
+	}
+}
+
 // Tests API with issuer claim in different field in the OIDC token
 func TestAPIWithIssuerClaimConfig(t *testing.T) {
 	emailSigner, emailIssuer := newOIDCIssuer(t)