Files
zot/pkg/api/bearer_aws_secrets_manager_test.go
T
2026-02-03 09:25:38 -08:00

482 lines
14 KiB
Go

package api_test
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/go-jose/go-jose/v4"
"github.com/golang-jwt/jwt/v5"
. "github.com/smartystreets/goconvey/convey"
zerr "zotregistry.dev/zot/v2/errors"
"zotregistry.dev/zot/v2/pkg/api"
apiconfig "zotregistry.dev/zot/v2/pkg/api/config"
"zotregistry.dev/zot/v2/pkg/log"
)
var (
errAWSConnection = errors.New("aws connection error")
errAWSConfig = errors.New("aws config error")
)
// mockSecretsManager implements api.AWSSecretsManagerClient for testing.
type mockSecretsManager struct {
secretString string
err error
callCount int
}
func (m *mockSecretsManager) GetSecretValue(
_ context.Context,
_ *secretsmanager.GetSecretValueInput,
_ ...func(*secretsmanager.Options),
) (*secretsmanager.GetSecretValueOutput, error) {
m.callCount++
if m.err != nil {
return nil, m.err
}
return &secretsmanager.GetSecretValueOutput{
SecretString: aws.String(m.secretString),
}, nil
}
// mockAWSImplementation implements api.AWSSecretsManagerProvider for testing.
type mockAWSImplementation struct {
client api.AWSSecretsManagerClient
loadErr error
}
func (m *mockAWSImplementation) LoadDefaultConfig(
_ context.Context,
_ ...func(*awsconfig.LoadOptions) error,
) (aws.Config, error) {
if m.loadErr != nil {
return aws.Config{}, m.loadErr
}
return aws.Config{}, nil
}
func (m *mockAWSImplementation) NewFromConfig(_ aws.Config) api.AWSSecretsManagerClient {
return m.client
}
// ed25519KeyToJWKS converts an ed25519.PublicKey to a single-key JWKS JSON string.
func ed25519KeyToJWKS(pub ed25519.PublicKey, kid string) string {
jwk := jose.JSONWebKey{
Key: pub,
KeyID: kid,
Algorithm: "EdDSA",
Use: "sig",
}
keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}
data, err := json.Marshal(keySet)
if err != nil {
panic(err)
}
return string(data)
}
// ed25519KeyToPEM converts an ed25519.PublicKey to PEM-encoded PKIX format.
func ed25519KeyToPEM(pub ed25519.PublicKey) string {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
panic(err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))
}
func TestNewAWSSecretsManagerAuthorizerValidation(t *testing.T) {
Convey("Test AWS Secrets Manager config validation", t, func() {
Convey("Zero refresh interval gets default", func() {
mock := &mockSecretsManager{}
conf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "my-secret",
RefreshInterval: 0,
}
_, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
// The default was applied (conf.RefreshInterval is mutated).
So(conf.RefreshInterval, ShouldEqual, time.Minute)
})
Convey("LoadDefaultConfig error is propagated", func() {
impl := &mockAWSImplementation{loadErr: errAWSConfig}
conf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "my-secret",
RefreshInterval: time.Hour,
}
_, err := api.NewAWSSecretsManager(conf, impl, log.NewLogger("error", ""))
So(err, ShouldNotBeNil)
So(err, ShouldWrap, zerr.ErrBadConfig)
So(err, ShouldWrap, errAWSConfig)
})
})
}
func TestAWSSecretsManagerGetPublicKeys(t *testing.T) {
Convey("Test GetPublicKeys with mock secrets manager", t, func() {
pubKey, _, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
secretJSON, err := json.Marshal(map[string]string{
"key-1": ed25519KeyToPEM(pubKey),
})
So(err, ShouldBeNil)
mock := &mockSecretsManager{secretString: string(secretJSON)}
conf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
authz, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
Convey("Keys are fetched on first call", func() {
keys, err := authz.GetPublicKeys(context.Background())
So(err, ShouldBeNil)
So(keys, ShouldContainKey, "key-1")
So(mock.callCount, ShouldEqual, 1)
})
Convey("Keys are cached within refresh interval", func() {
keys1, err := authz.GetPublicKeys(context.Background())
So(err, ShouldBeNil)
So(keys1, ShouldContainKey, "key-1")
keys2, err := authz.GetPublicKeys(context.Background())
So(err, ShouldBeNil)
So(keys2, ShouldContainKey, "key-1")
// Only one call to the mock — second call used the cache.
So(mock.callCount, ShouldEqual, 1)
})
Convey("AWS error is propagated", func() {
failMock := &mockSecretsManager{err: errAWSConnection}
failConf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
failAuthz, err := api.NewAWSSecretsManager(
failConf, &mockAWSImplementation{client: failMock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
_, err = failAuthz.GetPublicKeys(context.Background())
So(err, ShouldNotBeNil)
So(err, ShouldWrap, errAWSConnection)
})
Convey("Invalid JSON secret is rejected", func() {
badMock := &mockSecretsManager{secretString: "not-json"}
badConf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
badAuthz, err := api.NewAWSSecretsManager(
badConf, &mockAWSImplementation{client: badMock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
_, err = badAuthz.GetPublicKeys(context.Background())
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "failed to parse secret JSON")
})
Convey("Invalid PEM key in secret is rejected", func() {
badKeyJSON, err := json.Marshal(map[string]string{
"bad-key": "not-a-pem-key",
})
So(err, ShouldBeNil)
badMock := &mockSecretsManager{secretString: string(badKeyJSON)}
badConf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
badAuthz, err := api.NewAWSSecretsManager(
badConf, &mockAWSImplementation{client: badMock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
_, err = badAuthz.GetPublicKeys(context.Background())
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "failed to load public key")
})
Convey("JWKS-format key is parsed correctly", func() {
jwksPub, _, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
jwksJSON, err := json.Marshal(map[string]string{
"jwks-key": ed25519KeyToJWKS(jwksPub, "jwks-kid"),
})
So(err, ShouldBeNil)
jwksMock := &mockSecretsManager{secretString: string(jwksJSON)}
jwksConf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
jwksAuthz, err := api.NewAWSSecretsManager(
jwksConf, &mockAWSImplementation{client: jwksMock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
keys, err := jwksAuthz.GetPublicKeys(context.Background())
So(err, ShouldBeNil)
So(keys, ShouldContainKey, "jwks-key")
})
Convey("JWKS with multiple keys in one entry is rejected", func() {
// Build a JWKS with 2 keys to trigger the "expected 1 key" error.
jwksPub1, _, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
jwksPub2, _, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
{Key: jwksPub1, KeyID: "k1", Algorithm: "EdDSA", Use: "sig"},
{Key: jwksPub2, KeyID: "k2", Algorithm: "EdDSA", Use: "sig"},
}}
multiData, err := json.Marshal(keySet)
So(err, ShouldBeNil)
multiJSON, err := json.Marshal(map[string]string{
"multi-key": string(multiData),
})
So(err, ShouldBeNil)
multiMock := &mockSecretsManager{secretString: string(multiJSON)}
multiConf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
multiAuthz, err := api.NewAWSSecretsManager(
multiConf, &mockAWSImplementation{client: multiMock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
_, err = multiAuthz.GetPublicKeys(context.Background())
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "expected 1 key in JWKS, found 2")
})
})
}
func TestAWSSecretsManagerGetPublicKey(t *testing.T) {
Convey("Test GetPublicKey kid-based selection with mock", t, func() {
pubKey1, _, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
pubKey2, _, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
secretJSON, err := json.Marshal(map[string]string{
"kid-alpha": ed25519KeyToPEM(pubKey1),
"kid-beta": ed25519KeyToPEM(pubKey2),
})
So(err, ShouldBeNil)
mock := &mockSecretsManager{secretString: string(secretJSON)}
conf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
authz, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
Convey("Matching kid returns the correct key", func() {
token := &jwt.Token{Header: map[string]any{"kid": "kid-alpha"}}
key, err := authz.GetPublicKey(context.Background(), token)
So(err, ShouldBeNil)
So(key, ShouldNotBeNil)
})
Convey("Missing kid header is rejected", func() {
token := &jwt.Token{Header: map[string]any{}}
_, err := authz.GetPublicKey(context.Background(), token)
So(err, ShouldNotBeNil)
So(err, ShouldWrap, zerr.ErrInvalidBearerToken)
})
Convey("Non-string kid header is rejected", func() {
token := &jwt.Token{Header: map[string]any{"kid": 12345}}
_, err := authz.GetPublicKey(context.Background(), token)
So(err, ShouldNotBeNil)
So(err, ShouldWrap, zerr.ErrInvalidBearerToken)
})
Convey("Unknown kid is rejected", func() {
token := &jwt.Token{Header: map[string]any{"kid": "kid-unknown"}}
_, err := authz.GetPublicKey(context.Background(), token)
So(err, ShouldNotBeNil)
So(err, ShouldWrap, zerr.ErrInvalidBearerToken)
})
Convey("GetSecretValue error is propagated through GetPublicKey", func() {
failMock := &mockSecretsManager{err: errAWSConnection}
failConf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
failAuthz, err := api.NewAWSSecretsManager(
failConf, &mockAWSImplementation{client: failMock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
token := &jwt.Token{Header: map[string]any{"kid": "any-kid"}}
_, err = failAuthz.GetPublicKey(context.Background(), token)
So(err, ShouldNotBeNil)
So(err, ShouldWrap, errAWSConnection)
})
})
}
func TestAWSSecretsManagerBearerAuthorizerE2E(t *testing.T) {
Convey("Test BearerAuthorizer with ASM key function end-to-end", t, func() {
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
So(err, ShouldBeNil)
const kid = "e2e-test-key"
secretJSON, err := json.Marshal(map[string]string{
kid: ed25519KeyToPEM(pubKey),
})
So(err, ShouldBeNil)
mock := &mockSecretsManager{secretString: string(secretJSON)}
conf := &apiconfig.AWSSecretsManagerConfig{
Region: "us-east-1",
SecretName: "test-secret",
RefreshInterval: time.Hour,
}
authz, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", ""))
So(err, ShouldBeNil)
authorizer := api.NewBearerAuthorizer("realm", "service", authz.GetPublicKey)
Convey("Valid EdDSA token with matching kid is authorized", func() {
now := time.Now()
claims := api.ClaimsWithAccess{
Access: []api.ResourceAccess{
{
Name: "test-repo",
Type: "repository",
Actions: []string{"pull"},
},
},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
token.Header["kid"] = kid
signedToken, err := token.SignedString(privKey)
So(err, ShouldBeNil)
requested := &api.ResourceAction{
Type: "repository",
Name: "test-repo",
Action: "pull",
}
err = authorizer.Authorize(context.Background(), "Bearer "+signedToken, requested)
So(err, ShouldBeNil)
})
Convey("Token without kid header is rejected", func() {
now := time.Now()
claims := api.ClaimsWithAccess{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
// Deliberately omit kid header.
signedToken, err := token.SignedString(privKey)
So(err, ShouldBeNil)
err = authorizer.Authorize(context.Background(), "Bearer "+signedToken, nil)
So(err, ShouldWrap, zerr.ErrInvalidBearerToken)
})
Convey("Token with unknown kid is rejected", func() {
now := time.Now()
claims := api.ClaimsWithAccess{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
token.Header["kid"] = "nonexistent-kid"
signedToken, err := token.SignedString(privKey)
So(err, ShouldBeNil)
err = authorizer.Authorize(context.Background(), "Bearer "+signedToken, nil)
So(err, ShouldWrap, zerr.ErrInvalidBearerToken)
})
})
}
func TestAWSSecretsManagerProductionImplementation(t *testing.T) {
Convey("Test production implementation coverage", t, func() {
impl := api.AWSSecretsManagerProviderImplementation{}
Convey("LoadDefaultConfig does not panic", func() {
_, err := impl.LoadDefaultConfig(context.Background())
if err != nil {
t.Log("no aws creds")
} else {
t.Log("aws creds available")
}
})
Convey("NewFromConfig returns a non-nil client", func() {
client := impl.NewFromConfig(aws.Config{})
So(client, ShouldNotBeNil)
})
})
}