Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 113 additions & 2 deletions pkg/leeway/signing/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package signing
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/in-toto/in-toto-golang/in_toto"
Expand Down Expand Up @@ -88,7 +90,13 @@ func GenerateSignedSLSAAttestation(ctx context.Context, artifactPath string, git
}

sourceURI := fmt.Sprintf("%s/%s", githubCtx.ServerURL, githubCtx.Repository)
builderID := fmt.Sprintf("%s/%s", githubCtx.ServerURL, githubCtx.WorkflowRef)

// Extract builder ID from OIDC token to match certificate identity
// This is critical for compatibility with reusable workflows
builderID, err := extractBuilderIDFromOIDC(ctx, githubCtx)
if err != nil {
return nil, fmt.Errorf("failed to extract builder ID from OIDC token: %w", err)
}

log.WithFields(log.Fields{
"artifact": filepath.Base(artifactPath),
Expand Down Expand Up @@ -367,6 +375,109 @@ func validateSigstoreEnvironment() error {
return nil
}

// extractBuilderIDFromOIDC extracts the builder ID from the GitHub OIDC token.
// This ensures the builder ID matches the certificate identity issued by Fulcio, which is
// critical for slsa-verifier compatibility, especially with reusable workflows.
//
// For reusable workflows, the OIDC token contains:
// - job_workflow_ref claim: Points to the actual executing workflow (e.g., _build.yml)
// - sub claim: May contain job_workflow_ref embedded in colon-separated format
// - workflow_ref env var: Points to the calling workflow (e.g., build-main.yml)
//
// Fulcio uses the sub claim for the certificate identity. For reusable workflows,
// the sub claim includes job_workflow_ref in the format:
// repo:OWNER/REPO:ref:REF:job_workflow_ref:OWNER/REPO/.github/workflows/WORKFLOW@REF
func extractBuilderIDFromOIDC(ctx context.Context, githubCtx *GitHubContext) (string, error) {
// Fetch the OIDC token with sigstore audience
idToken, err := fetchGitHubOIDCToken(ctx, "sigstore")
if err != nil {
return "", fmt.Errorf("failed to fetch OIDC token: %w", err)
}

// Parse the JWT token to extract claims
// JWT format: header.payload.signature
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid JWT token format: expected 3 parts, got %d", len(parts))
}

// Decode the payload (second part)
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("failed to decode JWT payload: %w", err)
}

// Parse the payload JSON
var claims map[string]interface{}
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return "", fmt.Errorf("failed to parse JWT claims: %w", err)
}

// Extract the sub claim (required for Fulcio certificate identity)
sub, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(sub) == "" {
return "", fmt.Errorf("sub claim not found or empty in OIDC token")
}

// Try to extract job_workflow_ref from the sub claim first
//
// Context:
// When we call sign.Bundle() with the OIDC token, the Sigstore library sends it to Fulcio (Sigstore's CA).
// Fulcio extracts claims from the OIDC token and issues a short-lived certificate with the builder identity in the Subject Alternative Name (SAN).
// For verification to succeed, our attestation's builder ID must match what Fulcio puts in the certificate SAN.
//
// TODO: Verify if GitHub embeds job_workflow_ref in the sub claim or only provides it as top-level.
// GitHub docs show it as top-level, but we need to confirm what Fulcio actually uses. The current
// implementation tries both approaches to ensure we match Fulcio's extraction logic.
jobWorkflowRef := extractJobWorkflowRef(sub)

// If not found in sub, try the top-level job_workflow_ref claim
if jobWorkflowRef == "" {
if jwfRef, ok := claims["job_workflow_ref"].(string); ok && jwfRef != "" {
jobWorkflowRef = jwfRef
log.WithField("job_workflow_ref", jobWorkflowRef).Debug("Using top-level job_workflow_ref claim (not found in sub)")
}
}

if jobWorkflowRef == "" {
return "", fmt.Errorf("job_workflow_ref not found in sub claim or top-level claims: %s", sub)
}

// Construct the builder ID URL
builderID := fmt.Sprintf("%s/%s", githubCtx.ServerURL, jobWorkflowRef)

log.WithFields(log.Fields{
"sub_claim": sub,
"job_workflow_ref": jobWorkflowRef,
"builder_id": builderID,
}).Debug("Extracted builder ID from OIDC token")

return builderID, nil
}

// extractJobWorkflowRef extracts the job_workflow_ref from a GitHub OIDC sub claim.
// The sub claim format for reusable workflows is:
// repo:OWNER/REPO:ref:REF:job_workflow_ref:OWNER/REPO/.github/workflows/WORKFLOW@REF
//
// For direct workflows (non-reusable), the format is similar but job_workflow_ref
// points to the same workflow as workflow_ref.
func extractJobWorkflowRef(sub string) string {
// Split by colon to parse the structured claim
parts := strings.Split(sub, ":")

// Find the job_workflow_ref field
for i, part := range parts {
if part == "job_workflow_ref" && i+1 < len(parts) {
// Return everything after "job_workflow_ref:"
// This handles the case where the workflow path contains colons
return strings.Join(parts[i+1:], ":")
}
}

// If no job_workflow_ref found, return empty string
return ""
}

// fetchGitHubOIDCToken fetches an OIDC token from GitHub Actions for Sigstore.
// It uses the ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL
// environment variables to authenticate and retrieve a JWT token with the specified audience.
Expand Down Expand Up @@ -403,7 +514,7 @@ func fetchGitHubOIDCToken(ctx context.Context, audience string) (string, error)
if err != nil {
return "", fmt.Errorf("failed to fetch token: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

// Check response status
if resp.StatusCode != http.StatusOK {
Expand Down
Loading
Loading