mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
0e5a339f11
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
482 lines
14 KiB
Go
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)
|
|
})
|
|
})
|
|
}
|