Skip to content

Commit

Permalink
[feat]: add support for EC/ED25519 public keys for token authenticati…
Browse files Browse the repository at this point in the history
…on (#2998)

* feat: rework token auth to allow ED25519/EC public keys

Signed-off-by: evanebb <git@evanus.nl>

* fix: shadow err variable to hopefully avoid data race

Signed-off-by: evanebb <git@evanus.nl>

* fix: apply golangci-lint feedback

Signed-off-by: evanebb <git@evanus.nl>

* fix: simplify public key loading by only supporting certificates, fixes ED25519 certificate handling

Signed-off-by: evanebb <git@evanus.nl>

* test: add golang-jwt based test auth server and test RSA/EC/ED25519 keys

Signed-off-by: evanebb <git@evanus.nl>

* fix: restrict allowed signing algorithms as recommended by library

Signed-off-by: evanebb <git@evanus.nl>

* test: add more bearer authorizer tests

Signed-off-by: evanebb <git@evanus.nl>

* fix: apply more golangci-lint feedback

Signed-off-by: evanebb <git@evanus.nl>

* test: ensure chmod calls run on test failure for authn errors test

Signed-off-by: evanebb <git@evanus.nl>

* fix: verify issued-at in given token if present
Pulls the validation in-line with the old library

Signed-off-by: evanebb <git@evanus.nl>

---------

Signed-off-by: evanebb <git@evanus.nl>
  • Loading branch information
evanebb authored Mar 6, 2025
1 parent e7fb9c5 commit d465690
Show file tree
Hide file tree
Showing 11 changed files with 1,398 additions and 744 deletions.
4 changes: 4 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,8 @@ var (
ErrImageNotFound = errors.New("image not found")
ErrAmbiguousInput = errors.New("input is not specific enough")
ErrReceivedUnexpectedAuthHeader = errors.New("received unexpected www-authenticate header")
ErrNoBearerToken = errors.New("no bearer token given")
ErrInvalidBearerToken = errors.New("invalid bearer token given")
ErrInsufficientScope = errors.New("bearer token does not have sufficient scope")
ErrCouldNotLoadCertificate = errors.New("failed to load certificate")
)
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ require (
github.com/go-redis/redismock/v9 v9.2.0
github.com/go-redsync/redsync/v4 v4.13.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/go-containerregistry v0.20.3
github.com/google/go-github/v62 v62.0.0
github.com/google/uuid v1.6.0
Expand Down Expand Up @@ -273,7 +274,6 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
Expand Down
118 changes: 53 additions & 65 deletions pkg/api/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"

"github.com/chartmuseum/auth"
guuid "github.com/gofrs/uuid"
"github.com/google/go-github/v62/github"
"github.com/google/uuid"
Expand All @@ -39,9 +38,8 @@ import (
)

const (
bearerAuthDefaultAccessEntryType = "repository"
issuedAtOffset = 5 * time.Second
relyingPartyCookieMaxAge = 120
issuedAtOffset = 5 * time.Second
relyingPartyCookieMaxAge = 120
)

type AuthnMiddleware struct {
Expand Down Expand Up @@ -404,17 +402,17 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
}

func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
authorizer, err := auth.NewAuthorizer(&auth.AuthorizerOptions{
Realm: ctlr.Config.HTTP.Auth.Bearer.Realm,
Service: ctlr.Config.HTTP.Auth.Bearer.Service,
PublicKeyPath: ctlr.Config.HTTP.Auth.Bearer.Cert,
AccessEntryType: bearerAuthDefaultAccessEntryType,
EmptyDefaultNamespace: true,
})
certificate, err := loadCertificateFromFile(ctlr.Config.HTTP.Auth.Bearer.Cert)
if err != nil {
ctlr.Log.Panic().Err(err).Msg("failed to create bearer authorizer")
ctlr.Log.Panic().Err(err).Msg("failed to load certificate for bearer authentication")
}

authorizer := NewBearerAuthorizer(
ctlr.Config.HTTP.Auth.Bearer.Realm,
ctlr.Config.HTTP.Auth.Bearer.Service,
certificate.PublicKey,
)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodOptions {
Expand All @@ -425,8 +423,6 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
}

acCtrlr := NewAccessController(ctlr.Config)
vars := mux.Vars(request)
name := vars["name"]

// we want to bypass auth for mgmt route
isMgmtRequested := request.RequestURI == constants.FullMgmt
Expand All @@ -439,67 +435,40 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
return
}

action := auth.PullAction
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
action = auth.PushAction
}

var permissions *auth.Permission
var requestedAccess *ResourceAction

// Empty scope should be allowed according to the distribution auth spec
// This is only necessary for the bearer auth type
if request.RequestURI == "/v2/" && authorizer.Type == auth.BearerAuthAuthorizerType {
if header == "" {
// first request that clients make (without any header)
WWWAuthenticateHeader := fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"\"",
authorizer.Realm, authorizer.Service)
if request.RequestURI != "/v2/" {
// if this is not the base route, the requested repository/action must be authorized
vars := mux.Vars(request)
name := vars["name"]

permissions = &auth.Permission{
// challenge for the client to use to authenticate to /v2/
WWWAuthenticateHeader: WWWAuthenticateHeader,
Allowed: false,
}
} else {
// subsequent requests with token on /v2/
bearerTokenMatch := regexp.MustCompile("(?i)bearer (.*)")

signedString := bearerTokenMatch.ReplaceAllString(header, "$1")

// If the token is valid, our job is done
// Since this is the /v2 base path and we didn't pass a scope to the auth header in the previous step
// there is no access check to enforce
_, err := authorizer.TokenDecoder.DecodeToken(signedString)
if err != nil {
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
response.Header().Set("Content-Type", "application/json")
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))

return
}
action := "pull"
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
action = "push"
}

permissions = &auth.Permission{
Allowed: true,
}
requestedAccess = &ResourceAction{
Type: "repository",
Name: name,
Action: action,
}
} else {
var err error
}

// subsequent requests with token on /v2/<resource>/
permissions, err = authorizer.Authorize(header, action, name)
if err != nil {
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
err := authorizer.Authorize(header, requestedAccess)
if err != nil {
var challenge *AuthChallengeError
if errors.As(err, &challenge) {
ctlr.Log.Debug().Err(challenge).Msg("bearer token authorization failed")
response.Header().Set("Content-Type", "application/json")
zcommon.WriteJSON(response, http.StatusInternalServerError, apiErr.NewError(apiErr.UNSUPPORTED))
response.Header().Set("WWW-Authenticate", challenge.Header())
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))

return
}
}

if !permissions.Allowed {
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
response.Header().Set("Content-Type", "application/json")
response.Header().Set("WWW-Authenticate", permissions.WWWAuthenticateHeader)

zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))

return
}
Expand Down Expand Up @@ -932,3 +901,22 @@ func GenerateAPIKey(uuidGenerator guuid.Generator, log log.Logger,

return apiKey, apiKeyID.String(), err
}

func loadCertificateFromFile(path string) (*x509.Certificate, error) {
rawCert, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("%w: %w, path %s", zerr.ErrCouldNotLoadCertificate, err, path)
}

block, _ := pem.Decode(rawCert)
if block == nil {
return nil, fmt.Errorf("%w: no valid PEM data found", zerr.ErrCouldNotLoadCertificate)
}

cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("%w: %w", zerr.ErrCouldNotLoadCertificate, err)
}

return cert, nil
}
136 changes: 136 additions & 0 deletions pkg/api/bearer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package api

import (
"crypto"
"fmt"
"regexp"
"slices"

"github.com/golang-jwt/jwt/v5"

zerr "zotregistry.dev/zot/errors"
)

var bearerTokenMatch = regexp.MustCompile("(?i)bearer (.*)")

// ResourceAccess is a single entry in the private 'access' claim specified by the distribution token authentication
// specification.
type ResourceAccess struct {
Type string `json:"type"`
Name string `json:"name"`
Actions []string `json:"actions"`
}

type ResourceAction struct {
Type string `json:"type"`
Name string `json:"name"`
Action string `json:"action"`
}

// ClaimsWithAccess is a claim set containing the private 'access' claim specified by the distribution token
// authentication specification, in addition to the standard registered claims.
// https://distribution.github.io/distribution/spec/auth/jwt/
type ClaimsWithAccess struct {
Access []ResourceAccess `json:"access"`
jwt.RegisteredClaims
}

type AuthChallengeError struct {
err error
realm string
service string
resourceAction *ResourceAction
}

func (c AuthChallengeError) Error() string {
return c.err.Error()
}

// Header constructs an appropriate value for the WWW-Authenticate header to be returned to the client.
func (c AuthChallengeError) Header() string {
if c.resourceAction == nil {
// no access was requested, so return an empty scope
return fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"\"",
c.realm, c.service)
}

return fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"%s:%s:%s\"",
c.realm, c.service, c.resourceAction.Type, c.resourceAction.Name, c.resourceAction.Action)
}

type BearerAuthorizer struct {
realm string
service string
key crypto.PublicKey
}

func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) BearerAuthorizer {
return BearerAuthorizer{
realm: realm,
service: service,
key: key,
}
}

// Authorize verifies whether the bearer token in the given Authorization header is valid, and whether it has sufficient
// scope for the requested resource action. If an authorization error occurs (e.g. no token is given or the token has
// insufficient scope), an AuthChallengeError is returned as the error.
func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) error {
challenge := &AuthChallengeError{
realm: a.realm,
service: a.service,
resourceAction: requested,
}

if header == "" {
// if no bearer token is set in the authorization header, return the authentication challenge
challenge.err = zerr.ErrNoBearerToken

return challenge
}

signedString := bearerTokenMatch.ReplaceAllString(header, "$1")

token, err := jwt.ParseWithClaims(signedString, &ClaimsWithAccess{}, func(token *jwt.Token) (interface{}, error) {
return a.key, nil
}, jwt.WithValidMethods(a.allowedSigningAlgorithms()), jwt.WithIssuedAt())
if err != nil {
return fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err)
}

if requested == nil {
// the token is valid and no access is requested, so we do not have to validate the access claim
return nil
}

claims, ok := token.Claims.(*ClaimsWithAccess)
if !ok {
return fmt.Errorf("%w: invalid claims type", zerr.ErrInvalidBearerToken)
}

// check whether the requested access is allowed by the scope of the token
for _, allowed := range claims.Access {
if allowed.Type != requested.Type {
continue
}

if allowed.Name != requested.Name {
continue
}

if !slices.Contains(allowed.Actions, requested.Action) {
continue
}

// requested action is allowed, so don't return an error
return nil
}

challenge.err = zerr.ErrInsufficientScope

return challenge
}

func (a *BearerAuthorizer) allowedSigningAlgorithms() []string {
return []string{"EdDSA", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"}
}
Loading

0 comments on commit d465690

Please sign in to comment.