diff --git a/internal/authn/oidc/verifier.go b/internal/authn/oidc/verifier.go index c6d33fc..2fcc289 100644 --- a/internal/authn/oidc/verifier.go +++ b/internal/authn/oidc/verifier.go @@ -2,6 +2,9 @@ package oidc import ( "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" "fmt" "strconv" "strings" @@ -51,7 +54,14 @@ func (v *Verifier) Verify(ctx context.Context, in *core.Attestation) (*core.Work claims := jwt.MapClaims{} parsed, err := jwt.ParseWithClaims(in.Token, claims, func(token *jwt.Token) (any, error) { - return v.keyfunc(ctx, token) + key, err := v.keyfunc(ctx, token) + if err != nil { + return nil, err + } + if err := ensureSupportedSigningMethod(token, key); err != nil { + return nil, err + } + return key, nil }, jwt.WithIssuer(v.issuer), jwt.WithAudience(v.audience)) if err != nil { return nil, fmt.Errorf("%w: %v", core.ErrUnauthorized, err) @@ -141,3 +151,22 @@ func copyNumericClaim(attributes map[string]string, claims jwt.MapClaims, key st } } } + +func ensureSupportedSigningMethod(token *jwt.Token, key any) error { + switch key.(type) { + case ed25519.PublicKey: + if token.Method == jwt.SigningMethodEdDSA { + return nil + } + case *rsa.PublicKey: + switch token.Method.(type) { + case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS: + return nil + } + case *ecdsa.PublicKey: + if _, ok := token.Method.(*jwt.SigningMethodECDSA); ok { + return nil + } + } + return fmt.Errorf("%w: unexpected signing method %q", core.ErrUnauthorized, token.Method.Alg()) +} diff --git a/internal/authn/oidc/verifier_test.go b/internal/authn/oidc/verifier_test.go index 3219db1..1fbd85f 100644 --- a/internal/authn/oidc/verifier_test.go +++ b/internal/authn/oidc/verifier_test.go @@ -11,6 +11,23 @@ import ( "github.com/golang-jwt/jwt/v5" ) +func newTestVerifier(t *testing.T, publicKey ed25519.PublicKey, prefixes []string) *oidc.Verifier { + t.Helper() + + verifier, err := oidc.NewVerifier(oidc.Config{ + Issuer: "https://token.actions.githubusercontent.com", + Audience: "asb-control-plane", + AllowedSubjectPrefixes: prefixes, + Keyfunc: func(context.Context, *jwt.Token) (any, error) { + return publicKey, nil + }, + }) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + return verifier +} + func TestVerifier_VerifyGitHubActionsToken(t *testing.T) { t.Parallel() @@ -41,17 +58,7 @@ func TestVerifier_VerifyGitHubActionsToken(t *testing.T) { t.Fatalf("SignedString() error = %v", err) } - verifier, err := oidc.NewVerifier(oidc.Config{ - Issuer: "https://token.actions.githubusercontent.com", - Audience: "asb-control-plane", - AllowedSubjectPrefixes: []string{"repo:evalops/"}, - Keyfunc: func(context.Context, *jwt.Token) (any, error) { - return publicKey, nil - }, - }) - if err != nil { - t.Fatalf("NewVerifier() error = %v", err) - } + verifier := newTestVerifier(t, publicKey, []string{"repo:evalops/"}) identity, err := verifier.Verify(context.Background(), &core.Attestation{ Kind: core.AttestationKindOIDCJWT, @@ -93,22 +100,128 @@ func TestVerifier_RejectsUnexpectedSubjectPrefix(t *testing.T) { t.Fatalf("SignedString() error = %v", err) } - verifier, err := oidc.NewVerifier(oidc.Config{ - Issuer: "https://token.actions.githubusercontent.com", - Audience: "asb-control-plane", - AllowedSubjectPrefixes: []string{"repo:evalops/"}, - Keyfunc: func(context.Context, *jwt.Token) (any, error) { - return publicKey, nil - }, + verifier := newTestVerifier(t, publicKey, []string{"repo:evalops/"}) + + if _, err := verifier.Verify(context.Background(), &core.Attestation{ + Kind: core.AttestationKindOIDCJWT, + Token: raw, + }); err == nil { + t.Fatal("Verify() error = nil, want non-nil") + } +} + +func TestVerifier_AllowsAnySubjectWhenPrefixesUnset(t *testing.T) { + t.Parallel() + + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": "https://token.actions.githubusercontent.com", + "sub": "repo:other-org/repo:ref:refs/heads/main", + "aud": "asb-control-plane", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), }) + raw, err := token.SignedString(privateKey) if err != nil { - t.Fatalf("NewVerifier() error = %v", err) + t.Fatalf("SignedString() error = %v", err) + } + + verifier := newTestVerifier(t, publicKey, nil) + if _, err := verifier.Verify(context.Background(), &core.Attestation{ + Kind: core.AttestationKindOIDCJWT, + Token: raw, + }); err != nil { + t.Fatalf("Verify() error = %v", err) + } +} + +func TestVerifier_RejectsExpiredToken(t *testing.T) { + t.Parallel() + + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": "https://token.actions.githubusercontent.com", + "sub": "repo:evalops/asb:ref:refs/heads/main", + "aud": "asb-control-plane", + "exp": time.Now().Add(-time.Minute).Unix(), + "iat": time.Now().Add(-2 * time.Minute).Unix(), + }) + raw, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("SignedString() error = %v", err) } + verifier := newTestVerifier(t, publicKey, []string{"repo:evalops/"}) if _, err := verifier.Verify(context.Background(), &core.Attestation{ Kind: core.AttestationKindOIDCJWT, Token: raw, }); err == nil { - t.Fatal("Verify() error = nil, want non-nil") + t.Fatal("Verify() error = nil, want expired token failure") + } +} + +func TestVerifier_RejectsTokenBeforeNotBefore(t *testing.T) { + t.Parallel() + + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "iss": "https://token.actions.githubusercontent.com", + "sub": "repo:evalops/asb:ref:refs/heads/main", + "aud": "asb-control-plane", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + "nbf": time.Now().Add(time.Minute).Unix(), + }) + raw, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("SignedString() error = %v", err) + } + + verifier := newTestVerifier(t, publicKey, []string{"repo:evalops/"}) + if _, err := verifier.Verify(context.Background(), &core.Attestation{ + Kind: core.AttestationKindOIDCJWT, + Token: raw, + }); err == nil { + t.Fatal("Verify() error = nil, want not-before validation failure") + } +} + +func TestVerifier_RejectsUnexpectedSigningMethod(t *testing.T) { + t.Parallel() + + publicKey, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "https://token.actions.githubusercontent.com", + "sub": "repo:evalops/asb:ref:refs/heads/main", + "aud": "asb-control-plane", + "exp": time.Now().Add(5 * time.Minute).Unix(), + }) + raw, err := token.SignedString([]byte("shared-secret")) + if err != nil { + t.Fatalf("SignedString() error = %v", err) + } + + verifier := newTestVerifier(t, publicKey, []string{"repo:evalops/"}) + if _, err := verifier.Verify(context.Background(), &core.Attestation{ + Kind: core.AttestationKindOIDCJWT, + Token: raw, + }); err == nil { + t.Fatal("Verify() error = nil, want signing-method failure") } } diff --git a/internal/crypto/sessionjwt/manager_test.go b/internal/crypto/sessionjwt/manager_test.go index 37306a5..60e2ce9 100644 --- a/internal/crypto/sessionjwt/manager_test.go +++ b/internal/crypto/sessionjwt/manager_test.go @@ -10,15 +10,26 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func TestManagerSignAddsStandardClaims(t *testing.T) { - t.Parallel() +func newTestManager(t *testing.T, now time.Time) (*Manager, ed25519.PrivateKey) { + t.Helper() - now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) _, privateKey, err := ed25519.GenerateKey(nil) if err != nil { t.Fatalf("GenerateKey() error = %v", err) } + manager, err := NewManager(privateKey, WithNowFunc(func() time.Time { return now })) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + return manager, privateKey +} + +func TestManagerSignAddsStandardClaims(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) + _, privateKey := newTestManager(t, now) manager, err := NewManager(privateKey, WithIssuer("asb.example"), WithNowFunc(func() time.Time { return now })) if err != nil { t.Fatalf("NewManager() error = %v", err) @@ -76,48 +87,160 @@ func TestManagerVerifyRejectsMissingJTI(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) - _, privateKey, err := ed25519.GenerateKey(nil) + manager, privateKey := newTestManager(t, now) + + raw, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + SessionID: "sess_123", + TenantID: "t_acme", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-defaultClockSkew)), + Issuer: defaultIssuer, + }, + }).SignedString(privateKey) if err != nil { - t.Fatalf("GenerateKey() error = %v", err) + t.Fatalf("SignedString() error = %v", err) } - manager, err := NewManager(privateKey, WithNowFunc(func() time.Time { return now })) + if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "missing jti") { + t.Fatalf("Verify() error = %v, want missing jti", err) + } +} + +func TestManagerVerifyRejectsTokenBeforeNotBefore(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) + manager, privateKey := newTestManager(t, now) + + raw, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + SessionID: "sess_123", + TenantID: "t_acme", + RegisteredClaims: jwt.RegisteredClaims{ + ID: "tok_123", + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(time.Minute)), + Issuer: defaultIssuer, + }, + }).SignedString(privateKey) if err != nil { - t.Fatalf("NewManager() error = %v", err) + t.Fatalf("SignedString() error = %v", err) + } + + if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "token is not valid yet") { + t.Fatalf("Verify() error = %v, want not-before validation failure", err) } +} + +func TestManagerVerifyRejectsExpiredToken(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) + manager, privateKey := newTestManager(t, now) raw, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ SessionID: "sess_123", TenantID: "t_acme", RegisteredClaims: jwt.RegisteredClaims{ + ID: "tok_123", + ExpiresAt: jwt.NewNumericDate(now.Add(-time.Minute)), + IssuedAt: jwt.NewNumericDate(now.Add(-2 * time.Minute)), + NotBefore: jwt.NewNumericDate(now.Add(-3 * time.Minute)), + Issuer: defaultIssuer, + }, + }).SignedString(privateKey) + if err != nil { + t.Fatalf("SignedString() error = %v", err) + } + + if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "token is expired") { + t.Fatalf("Verify() error = %v, want expiration failure", err) + } +} + +func TestManagerVerifyRejectsUnexpectedSigningMethod(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) + manager, _ := newTestManager(t, now) + + raw, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{ + SessionID: "sess_123", + TenantID: "t_acme", + RegisteredClaims: jwt.RegisteredClaims{ + ID: "tok_123", ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-defaultClockSkew)), Issuer: defaultIssuer, }, - }).SignedString(privateKey) + }).SignedString([]byte("shared-secret")) if err != nil { t.Fatalf("SignedString() error = %v", err) } - if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "missing jti") { - t.Fatalf("Verify() error = %v, want missing jti", err) + if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "unexpected signing method") { + t.Fatalf("Verify() error = %v, want signing-method failure", err) } } -func TestManagerVerifyRejectsTokenBeforeNotBefore(t *testing.T) { +func TestManagerVerifyRejectsMissingSessionID(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) - _, privateKey, err := ed25519.GenerateKey(nil) + manager, privateKey := newTestManager(t, now) + + raw, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + TenantID: "t_acme", + RegisteredClaims: jwt.RegisteredClaims{ + ID: "tok_123", + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-defaultClockSkew)), + Issuer: defaultIssuer, + }, + }).SignedString(privateKey) if err != nil { - t.Fatalf("GenerateKey() error = %v", err) + t.Fatalf("SignedString() error = %v", err) } - manager, err := NewManager(privateKey, WithNowFunc(func() time.Time { return now })) + if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "missing required session claims") { + t.Fatalf("Verify() error = %v, want missing required session claims", err) + } +} + +func TestManagerVerifyRejectsMissingTenantID(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) + manager, privateKey := newTestManager(t, now) + + raw, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ + SessionID: "sess_123", + RegisteredClaims: jwt.RegisteredClaims{ + ID: "tok_123", + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-defaultClockSkew)), + Issuer: defaultIssuer, + }, + }).SignedString(privateKey) if err != nil { - t.Fatalf("NewManager() error = %v", err) + t.Fatalf("SignedString() error = %v", err) + } + + if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "missing required session claims") { + t.Fatalf("Verify() error = %v, want missing required session claims", err) } +} + +func TestManagerVerifyRejectsTamperedToken(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 15, 22, 0, 0, 0, time.UTC) + manager, privateKey := newTestManager(t, now) raw, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims{ SessionID: "sess_123", @@ -126,7 +249,7 @@ func TestManagerVerifyRejectsTokenBeforeNotBefore(t *testing.T) { ID: "tok_123", ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now.Add(time.Minute)), + NotBefore: jwt.NewNumericDate(now.Add(-defaultClockSkew)), Issuer: defaultIssuer, }, }).SignedString(privateKey) @@ -134,7 +257,21 @@ func TestManagerVerifyRejectsTokenBeforeNotBefore(t *testing.T) { t.Fatalf("SignedString() error = %v", err) } - if _, err := manager.Verify(raw); err == nil || !strings.Contains(err.Error(), "token is not valid yet") { - t.Fatalf("Verify() error = %v, want not-before validation failure", err) + parts := strings.Split(raw, ".") + if len(parts) != 3 { + t.Fatalf("expected jwt token, got %q", raw) + } + signature := []byte(parts[2]) + if len(signature) == 0 { + t.Fatalf("expected signature segment, got %q", raw) + } + if signature[0] == 'A' { + signature[0] = 'B' + } else { + signature[0] = 'A' + } + tampered := strings.Join([]string{parts[0], parts[1], string(signature)}, ".") + if _, err := manager.Verify(tampered); err == nil { + t.Fatal("Verify() error = nil, want tampered token failure") } }