@@ -3,6 +3,7 @@ package signing
33import (
44 "context"
55 "crypto/sha256"
6+ "encoding/base64"
67 "encoding/hex"
78 "encoding/json"
89 "fmt"
@@ -1210,3 +1211,360 @@ func TestFetchGitHubOIDCToken(t *testing.T) {
12101211 })
12111212 }
12121213}
1214+
1215+ // TestExtractJobWorkflowRef tests the extraction of job_workflow_ref from OIDC sub claims
1216+ func TestExtractJobWorkflowRef (t * testing.T ) {
1217+ tests := []struct {
1218+ name string
1219+ subClaim string
1220+ expected string
1221+ }{
1222+ {
1223+ name : "reusable workflow with job_workflow_ref" ,
1224+ subClaim : "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b" ,
1225+ expected : "example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b" ,
1226+ },
1227+ {
1228+ name : "direct workflow (non-reusable)" ,
1229+ subClaim : "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1230+ expected : "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1231+ },
1232+ {
1233+ name : "workflow path with colons" ,
1234+ subClaim : "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/build:test.yml@refs/heads/main" ,
1235+ expected : "org/repo/.github/workflows/build:test.yml@refs/heads/main" ,
1236+ },
1237+ {
1238+ name : "missing job_workflow_ref" ,
1239+ subClaim : "repo:example-org/example-repo:ref:refs/heads/main" ,
1240+ expected : "" ,
1241+ },
1242+ {
1243+ name : "empty sub claim" ,
1244+ subClaim : "" ,
1245+ expected : "" ,
1246+ },
1247+ {
1248+ name : "malformed sub claim" ,
1249+ subClaim : "invalid:format" ,
1250+ expected : "" ,
1251+ },
1252+ {
1253+ name : "job_workflow_ref at end without value" ,
1254+ subClaim : "repo:org/repo:ref:refs/heads/main:job_workflow_ref:" ,
1255+ expected : "" ,
1256+ },
1257+ {
1258+ name : "multiple colons in workflow path" ,
1259+ subClaim : "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/test:build:deploy.yml@refs/heads/main" ,
1260+ expected : "org/repo/.github/workflows/test:build:deploy.yml@refs/heads/main" ,
1261+ },
1262+ }
1263+
1264+ for _ , tt := range tests {
1265+ t .Run (tt .name , func (t * testing.T ) {
1266+ result := extractJobWorkflowRef (tt .subClaim )
1267+ assert .Equal (t , tt .expected , result )
1268+ })
1269+ }
1270+ }
1271+
1272+ // TestExtractJobWorkflowRef_RealWorldExamples tests with actual GitHub OIDC token formats
1273+ func TestExtractJobWorkflowRef_RealWorldExamples (t * testing.T ) {
1274+ tests := []struct {
1275+ name string
1276+ subClaim string
1277+ expected string
1278+ description string
1279+ }{
1280+ {
1281+ name : "GitHub Actions reusable workflow" ,
1282+ subClaim : "repo:gitpod-io/gitpod:environment:production:ref:refs/heads/main:job_workflow_ref:gitpod-io/gitpod/.github/workflows/_build-image.yml@refs/heads/main" ,
1283+ expected : "gitpod-io/gitpod/.github/workflows/_build-image.yml@refs/heads/main" ,
1284+ description : "Reusable workflow with environment claim" ,
1285+ },
1286+ {
1287+ name : "Pull request workflow" ,
1288+ subClaim : "repo:gitpod-io/leeway:ref:refs/pull/264/merge:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/pull/264/merge" ,
1289+ expected : "gitpod-io/leeway/.github/workflows/build.yml@refs/pull/264/merge" ,
1290+ description : "Pull request merge ref" ,
1291+ },
1292+ {
1293+ name : "Tag-triggered workflow" ,
1294+ subClaim : "repo:org/repo:ref:refs/tags/v1.0.0:job_workflow_ref:org/repo/.github/workflows/release.yml@refs/tags/v1.0.0" ,
1295+ expected : "org/repo/.github/workflows/release.yml@refs/tags/v1.0.0" ,
1296+ description : "Tag reference" ,
1297+ },
1298+ }
1299+
1300+ for _ , tt := range tests {
1301+ t .Run (tt .name , func (t * testing.T ) {
1302+ result := extractJobWorkflowRef (tt .subClaim )
1303+ assert .Equal (t , tt .expected , result , tt .description )
1304+ })
1305+ }
1306+ }
1307+
1308+ // Helper function to create base64url encoded strings for JWT tokens
1309+ func base64EncodeForTest (s string ) string {
1310+ return strings .TrimRight (base64 .URLEncoding .EncodeToString ([]byte (s )), "=" )
1311+ }
1312+
1313+ // TestExtractBuilderIDFromOIDC tests the extraction of builder ID from OIDC tokens
1314+ func TestExtractBuilderIDFromOIDC (t * testing.T ) {
1315+ tests := []struct {
1316+ name string
1317+ setupServer func () * httptest.Server
1318+ githubCtx * GitHubContext
1319+ expectError bool
1320+ expectedID string
1321+ errorMsg string
1322+ }{
1323+ {
1324+ name : "valid OIDC token with reusable workflow" ,
1325+ setupServer : func () * httptest.Server {
1326+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1327+ header := base64EncodeForTest (`{"alg":"RS256","typ":"JWT"}` )
1328+ payload := base64EncodeForTest (`{
1329+ "sub": "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b",
1330+ "aud": "sigstore",
1331+ "iss": "https://token.actions.githubusercontent.com"
1332+ }` )
1333+ signature := base64EncodeForTest ("fake-signature" )
1334+ token := fmt .Sprintf ("%s.%s.%s" , header , payload , signature )
1335+
1336+ w .Header ().Set ("Content-Type" , "application/json" )
1337+ json .NewEncoder (w ).Encode (map [string ]string {"value" : token })
1338+ }))
1339+ },
1340+ githubCtx : & GitHubContext {
1341+ ServerURL : "https://github.com" ,
1342+ Repository : "example-org/example-repo" ,
1343+ WorkflowRef : "example-org/example-repo/.github/workflows/calling-workflow.yml@refs/heads/main" ,
1344+ },
1345+ expectError : false ,
1346+ expectedID : "https://github.com/example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b" ,
1347+ },
1348+ {
1349+ name : "valid OIDC token with direct workflow" ,
1350+ setupServer : func () * httptest.Server {
1351+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1352+ header := base64EncodeForTest (`{"alg":"RS256","typ":"JWT"}` )
1353+ payload := base64EncodeForTest (`{
1354+ "sub": "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main",
1355+ "aud": "sigstore"
1356+ }` )
1357+ signature := base64EncodeForTest ("fake-signature" )
1358+ token := fmt .Sprintf ("%s.%s.%s" , header , payload , signature )
1359+
1360+ w .Header ().Set ("Content-Type" , "application/json" )
1361+ json .NewEncoder (w ).Encode (map [string ]string {"value" : token })
1362+ }))
1363+ },
1364+ githubCtx : & GitHubContext {
1365+ ServerURL : "https://github.com" ,
1366+ Repository : "gitpod-io/leeway" ,
1367+ WorkflowRef : "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1368+ },
1369+ expectError : false ,
1370+ expectedID : "https://github.com/gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1371+ },
1372+ {
1373+ name : "invalid JWT format - only 2 parts" ,
1374+ setupServer : func () * httptest.Server {
1375+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1376+ token := "header.payload"
1377+ w .Header ().Set ("Content-Type" , "application/json" )
1378+ json .NewEncoder (w ).Encode (map [string ]string {"value" : token })
1379+ }))
1380+ },
1381+ githubCtx : & GitHubContext {
1382+ ServerURL : "https://github.com" ,
1383+ Repository : "org/repo" ,
1384+ },
1385+ expectError : true ,
1386+ errorMsg : "invalid JWT token format" ,
1387+ },
1388+ {
1389+ name : "missing sub claim" ,
1390+ setupServer : func () * httptest.Server {
1391+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1392+ header := base64EncodeForTest (`{"alg":"RS256","typ":"JWT"}` )
1393+ payload := base64EncodeForTest (`{"aud": "sigstore"}` )
1394+ signature := base64EncodeForTest ("fake-signature" )
1395+ token := fmt .Sprintf ("%s.%s.%s" , header , payload , signature )
1396+
1397+ w .Header ().Set ("Content-Type" , "application/json" )
1398+ json .NewEncoder (w ).Encode (map [string ]string {"value" : token })
1399+ }))
1400+ },
1401+ githubCtx : & GitHubContext {
1402+ ServerURL : "https://github.com" ,
1403+ Repository : "org/repo" ,
1404+ },
1405+ expectError : true ,
1406+ errorMsg : "sub claim not found" ,
1407+ },
1408+ {
1409+ name : "missing job_workflow_ref in sub claim" ,
1410+ setupServer : func () * httptest.Server {
1411+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1412+ header := base64EncodeForTest (`{"alg":"RS256","typ":"JWT"}` )
1413+ payload := base64EncodeForTest (`{
1414+ "sub": "repo:org/repo:ref:refs/heads/main",
1415+ "aud": "sigstore"
1416+ }` )
1417+ signature := base64EncodeForTest ("fake-signature" )
1418+ token := fmt .Sprintf ("%s.%s.%s" , header , payload , signature )
1419+
1420+ w .Header ().Set ("Content-Type" , "application/json" )
1421+ json .NewEncoder (w ).Encode (map [string ]string {"value" : token })
1422+ }))
1423+ },
1424+ githubCtx : & GitHubContext {
1425+ ServerURL : "https://github.com" ,
1426+ Repository : "org/repo" ,
1427+ },
1428+ expectError : true ,
1429+ errorMsg : "job_workflow_ref not found" ,
1430+ },
1431+ {
1432+ name : "OIDC token fetch failure" ,
1433+ setupServer : func () * httptest.Server {
1434+ return httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1435+ w .WriteHeader (http .StatusUnauthorized )
1436+ }))
1437+ },
1438+ githubCtx : & GitHubContext {
1439+ ServerURL : "https://github.com" ,
1440+ Repository : "org/repo" ,
1441+ },
1442+ expectError : true ,
1443+ errorMsg : "failed to fetch OIDC token" ,
1444+ },
1445+ }
1446+
1447+ for _ , tt := range tests {
1448+ t .Run (tt .name , func (t * testing.T ) {
1449+ server := tt .setupServer ()
1450+ defer server .Close ()
1451+
1452+ oldRequestURL := os .Getenv ("ACTIONS_ID_TOKEN_REQUEST_URL" )
1453+ oldRequestToken := os .Getenv ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" )
1454+ defer func () {
1455+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_URL" , oldRequestURL )
1456+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" , oldRequestToken )
1457+ }()
1458+
1459+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_URL" , server .URL )
1460+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" , "test-token" )
1461+
1462+ builderID , err := extractBuilderIDFromOIDC (context .Background (), tt .githubCtx )
1463+
1464+ if tt .expectError {
1465+ assert .Error (t , err )
1466+ if tt .errorMsg != "" {
1467+ assert .Contains (t , err .Error (), tt .errorMsg )
1468+ }
1469+ } else {
1470+ assert .NoError (t , err )
1471+ assert .Equal (t , tt .expectedID , builderID )
1472+ }
1473+ })
1474+ }
1475+ }
1476+
1477+ // TestBuilderIDMatchesCertificateIdentity is the critical regression test
1478+ // It verifies that the builder ID extracted from OIDC matches what Fulcio
1479+ // would use for the certificate identity, preventing verification failures
1480+ func TestBuilderIDMatchesCertificateIdentity (t * testing.T ) {
1481+ tests := []struct {
1482+ name string
1483+ oidcSubClaim string
1484+ githubWorkflowRef string
1485+ expectedBuilderID string
1486+ shouldMatchWorkflowRef bool
1487+ description string
1488+ }{
1489+ {
1490+ name : "reusable workflow - builder ID must match OIDC not GITHUB_WORKFLOW_REF" ,
1491+ oidcSubClaim : "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b" ,
1492+ githubWorkflowRef : "example-org/example-repo/.github/workflows/calling-workflow.yml@refs/heads/main" ,
1493+ expectedBuilderID : "https://github.com/example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b" ,
1494+ shouldMatchWorkflowRef : false ,
1495+ description : "For reusable workflows, certificate identity comes from OIDC sub claim (actual executing workflow), not GITHUB_WORKFLOW_REF (calling workflow)" ,
1496+ },
1497+ {
1498+ name : "direct workflow - builder ID matches both OIDC and GITHUB_WORKFLOW_REF" ,
1499+ oidcSubClaim : "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1500+ githubWorkflowRef : "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1501+ expectedBuilderID : "https://github.com/gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main" ,
1502+ shouldMatchWorkflowRef : true ,
1503+ description : "For direct workflows, OIDC and GITHUB_WORKFLOW_REF point to the same workflow" ,
1504+ },
1505+ {
1506+ name : "nested reusable workflow" ,
1507+ oidcSubClaim : "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/_internal-build.yml@refs/heads/feature" ,
1508+ githubWorkflowRef : "org/repo/.github/workflows/main-workflow.yml@refs/heads/main" ,
1509+ expectedBuilderID : "https://github.com/org/repo/.github/workflows/_internal-build.yml@refs/heads/feature" ,
1510+ shouldMatchWorkflowRef : false ,
1511+ description : "Nested reusable workflows also use OIDC sub claim for certificate identity" ,
1512+ },
1513+ }
1514+
1515+ for _ , tt := range tests {
1516+ t .Run (tt .name , func (t * testing.T ) {
1517+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1518+ header := base64EncodeForTest (`{"alg":"RS256","typ":"JWT"}` )
1519+ payload := base64EncodeForTest (fmt .Sprintf (`{
1520+ "sub": "%s",
1521+ "aud": "sigstore",
1522+ "iss": "https://token.actions.githubusercontent.com"
1523+ }` , tt .oidcSubClaim ))
1524+ signature := base64EncodeForTest ("fake-signature" )
1525+ token := fmt .Sprintf ("%s.%s.%s" , header , payload , signature )
1526+
1527+ w .Header ().Set ("Content-Type" , "application/json" )
1528+ json .NewEncoder (w ).Encode (map [string ]string {"value" : token })
1529+ }))
1530+ defer server .Close ()
1531+
1532+ oldRequestURL := os .Getenv ("ACTIONS_ID_TOKEN_REQUEST_URL" )
1533+ oldRequestToken := os .Getenv ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" )
1534+ defer func () {
1535+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_URL" , oldRequestURL )
1536+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" , oldRequestToken )
1537+ }()
1538+
1539+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_URL" , server .URL )
1540+ os .Setenv ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" , "test-token" )
1541+
1542+ githubCtx := & GitHubContext {
1543+ ServerURL : "https://github.com" ,
1544+ Repository : "example-org/example-repo" ,
1545+ WorkflowRef : tt .githubWorkflowRef ,
1546+ }
1547+
1548+ builderID , err := extractBuilderIDFromOIDC (context .Background (), githubCtx )
1549+ require .NoError (t , err , tt .description )
1550+
1551+ assert .Equal (t , tt .expectedBuilderID , builderID , tt .description )
1552+
1553+ workflowRefBasedID := fmt .Sprintf ("%s/%s" , githubCtx .ServerURL , tt .githubWorkflowRef )
1554+ if tt .shouldMatchWorkflowRef {
1555+ assert .Equal (t , workflowRefBasedID , builderID ,
1556+ "For direct workflows, builder ID should match GITHUB_WORKFLOW_REF-based ID" )
1557+ } else {
1558+ assert .NotEqual (t , workflowRefBasedID , builderID ,
1559+ "For reusable workflows, builder ID must NOT match GITHUB_WORKFLOW_REF-based ID - this is the critical fix" )
1560+ }
1561+
1562+ jobWorkflowRef := extractJobWorkflowRef (tt .oidcSubClaim )
1563+ require .NotEmpty (t , jobWorkflowRef , "job_workflow_ref should be extractable from sub claim" )
1564+
1565+ expectedFromJobWorkflowRef := fmt .Sprintf ("%s/%s" , githubCtx .ServerURL , jobWorkflowRef )
1566+ assert .Equal (t , expectedFromJobWorkflowRef , builderID ,
1567+ "Builder ID must be constructed from OIDC job_workflow_ref to match Fulcio certificate identity" )
1568+ })
1569+ }
1570+ }
0 commit comments