Skip to content

Commit d465690

Browse files
authored
[feat]: add support for EC/ED25519 public keys for token authentication (#2998)
* feat: rework token auth to allow ED25519/EC public keys Signed-off-by: evanebb <[email protected]> * fix: shadow err variable to hopefully avoid data race Signed-off-by: evanebb <[email protected]> * fix: apply golangci-lint feedback Signed-off-by: evanebb <[email protected]> * fix: simplify public key loading by only supporting certificates, fixes ED25519 certificate handling Signed-off-by: evanebb <[email protected]> * test: add golang-jwt based test auth server and test RSA/EC/ED25519 keys Signed-off-by: evanebb <[email protected]> * fix: restrict allowed signing algorithms as recommended by library Signed-off-by: evanebb <[email protected]> * test: add more bearer authorizer tests Signed-off-by: evanebb <[email protected]> * fix: apply more golangci-lint feedback Signed-off-by: evanebb <[email protected]> * test: ensure chmod calls run on test failure for authn errors test Signed-off-by: evanebb <[email protected]> * fix: verify issued-at in given token if present Pulls the validation in-line with the old library Signed-off-by: evanebb <[email protected]> --------- Signed-off-by: evanebb <[email protected]>
1 parent e7fb9c5 commit d465690

File tree

11 files changed

+1398
-744
lines changed

11 files changed

+1398
-744
lines changed

errors/errors.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,8 @@ var (
175175
ErrImageNotFound = errors.New("image not found")
176176
ErrAmbiguousInput = errors.New("input is not specific enough")
177177
ErrReceivedUnexpectedAuthHeader = errors.New("received unexpected www-authenticate header")
178+
ErrNoBearerToken = errors.New("no bearer token given")
179+
ErrInvalidBearerToken = errors.New("invalid bearer token given")
180+
ErrInsufficientScope = errors.New("bearer token does not have sufficient scope")
181+
ErrCouldNotLoadCertificate = errors.New("failed to load certificate")
178182
)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/go-redis/redismock/v9 v9.2.0
3434
github.com/go-redsync/redsync/v4 v4.13.0
3535
github.com/gofrs/uuid v4.4.0+incompatible
36+
github.com/golang-jwt/jwt/v5 v5.2.1
3637
github.com/google/go-containerregistry v0.20.3
3738
github.com/google/go-github/v62 v62.0.0
3839
github.com/google/uuid v1.6.0
@@ -273,7 +274,6 @@ require (
273274
github.com/gogo/protobuf v1.3.2 // indirect
274275
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
275276
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
276-
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
277277
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
278278
github.com/golang/protobuf v1.5.4 // indirect
279279
github.com/golang/snappy v0.0.4 // indirect

pkg/api/authn.go

Lines changed: 53 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@ import (
66
"crypto/x509"
77
"encoding/base64"
88
"encoding/hex"
9+
"encoding/pem"
910
"errors"
1011
"fmt"
1112
"net"
1213
"net/http"
1314
"os"
14-
"regexp"
1515
"strconv"
1616
"strings"
1717
"time"
1818

19-
"github.com/chartmuseum/auth"
2019
guuid "github.com/gofrs/uuid"
2120
"github.com/google/go-github/v62/github"
2221
"github.com/google/uuid"
@@ -39,9 +38,8 @@ import (
3938
)
4039

4140
const (
42-
bearerAuthDefaultAccessEntryType = "repository"
43-
issuedAtOffset = 5 * time.Second
44-
relyingPartyCookieMaxAge = 120
41+
issuedAtOffset = 5 * time.Second
42+
relyingPartyCookieMaxAge = 120
4543
)
4644

4745
type AuthnMiddleware struct {
@@ -404,17 +402,17 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
404402
}
405403

406404
func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
407-
authorizer, err := auth.NewAuthorizer(&auth.AuthorizerOptions{
408-
Realm: ctlr.Config.HTTP.Auth.Bearer.Realm,
409-
Service: ctlr.Config.HTTP.Auth.Bearer.Service,
410-
PublicKeyPath: ctlr.Config.HTTP.Auth.Bearer.Cert,
411-
AccessEntryType: bearerAuthDefaultAccessEntryType,
412-
EmptyDefaultNamespace: true,
413-
})
405+
certificate, err := loadCertificateFromFile(ctlr.Config.HTTP.Auth.Bearer.Cert)
414406
if err != nil {
415-
ctlr.Log.Panic().Err(err).Msg("failed to create bearer authorizer")
407+
ctlr.Log.Panic().Err(err).Msg("failed to load certificate for bearer authentication")
416408
}
417409

410+
authorizer := NewBearerAuthorizer(
411+
ctlr.Config.HTTP.Auth.Bearer.Realm,
412+
ctlr.Config.HTTP.Auth.Bearer.Service,
413+
certificate.PublicKey,
414+
)
415+
418416
return func(next http.Handler) http.Handler {
419417
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
420418
if request.Method == http.MethodOptions {
@@ -425,8 +423,6 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
425423
}
426424

427425
acCtrlr := NewAccessController(ctlr.Config)
428-
vars := mux.Vars(request)
429-
name := vars["name"]
430426

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

442-
action := auth.PullAction
443-
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
444-
action = auth.PushAction
445-
}
446-
447-
var permissions *auth.Permission
438+
var requestedAccess *ResourceAction
448439

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

457-
permissions = &auth.Permission{
458-
// challenge for the client to use to authenticate to /v2/
459-
WWWAuthenticateHeader: WWWAuthenticateHeader,
460-
Allowed: false,
461-
}
462-
} else {
463-
// subsequent requests with token on /v2/
464-
bearerTokenMatch := regexp.MustCompile("(?i)bearer (.*)")
465-
466-
signedString := bearerTokenMatch.ReplaceAllString(header, "$1")
467-
468-
// If the token is valid, our job is done
469-
// Since this is the /v2 base path and we didn't pass a scope to the auth header in the previous step
470-
// there is no access check to enforce
471-
_, err := authorizer.TokenDecoder.DecodeToken(signedString)
472-
if err != nil {
473-
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
474-
response.Header().Set("Content-Type", "application/json")
475-
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))
476-
477-
return
478-
}
445+
action := "pull"
446+
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
447+
action = "push"
448+
}
479449

480-
permissions = &auth.Permission{
481-
Allowed: true,
482-
}
450+
requestedAccess = &ResourceAction{
451+
Type: "repository",
452+
Name: name,
453+
Action: action,
483454
}
484-
} else {
485-
var err error
455+
}
486456

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

494466
return
495467
}
496-
}
497468

498-
if !permissions.Allowed {
469+
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
499470
response.Header().Set("Content-Type", "application/json")
500-
response.Header().Set("WWW-Authenticate", permissions.WWWAuthenticateHeader)
501-
502-
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
471+
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))
503472

504473
return
505474
}
@@ -932,3 +901,22 @@ func GenerateAPIKey(uuidGenerator guuid.Generator, log log.Logger,
932901

933902
return apiKey, apiKeyID.String(), err
934903
}
904+
905+
func loadCertificateFromFile(path string) (*x509.Certificate, error) {
906+
rawCert, err := os.ReadFile(path)
907+
if err != nil {
908+
return nil, fmt.Errorf("%w: %w, path %s", zerr.ErrCouldNotLoadCertificate, err, path)
909+
}
910+
911+
block, _ := pem.Decode(rawCert)
912+
if block == nil {
913+
return nil, fmt.Errorf("%w: no valid PEM data found", zerr.ErrCouldNotLoadCertificate)
914+
}
915+
916+
cert, err := x509.ParseCertificate(block.Bytes)
917+
if err != nil {
918+
return nil, fmt.Errorf("%w: %w", zerr.ErrCouldNotLoadCertificate, err)
919+
}
920+
921+
return cert, nil
922+
}

pkg/api/bearer.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package api
2+
3+
import (
4+
"crypto"
5+
"fmt"
6+
"regexp"
7+
"slices"
8+
9+
"github.com/golang-jwt/jwt/v5"
10+
11+
zerr "zotregistry.dev/zot/errors"
12+
)
13+
14+
var bearerTokenMatch = regexp.MustCompile("(?i)bearer (.*)")
15+
16+
// ResourceAccess is a single entry in the private 'access' claim specified by the distribution token authentication
17+
// specification.
18+
type ResourceAccess struct {
19+
Type string `json:"type"`
20+
Name string `json:"name"`
21+
Actions []string `json:"actions"`
22+
}
23+
24+
type ResourceAction struct {
25+
Type string `json:"type"`
26+
Name string `json:"name"`
27+
Action string `json:"action"`
28+
}
29+
30+
// ClaimsWithAccess is a claim set containing the private 'access' claim specified by the distribution token
31+
// authentication specification, in addition to the standard registered claims.
32+
// https://distribution.github.io/distribution/spec/auth/jwt/
33+
type ClaimsWithAccess struct {
34+
Access []ResourceAccess `json:"access"`
35+
jwt.RegisteredClaims
36+
}
37+
38+
type AuthChallengeError struct {
39+
err error
40+
realm string
41+
service string
42+
resourceAction *ResourceAction
43+
}
44+
45+
func (c AuthChallengeError) Error() string {
46+
return c.err.Error()
47+
}
48+
49+
// Header constructs an appropriate value for the WWW-Authenticate header to be returned to the client.
50+
func (c AuthChallengeError) Header() string {
51+
if c.resourceAction == nil {
52+
// no access was requested, so return an empty scope
53+
return fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"\"",
54+
c.realm, c.service)
55+
}
56+
57+
return fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"%s:%s:%s\"",
58+
c.realm, c.service, c.resourceAction.Type, c.resourceAction.Name, c.resourceAction.Action)
59+
}
60+
61+
type BearerAuthorizer struct {
62+
realm string
63+
service string
64+
key crypto.PublicKey
65+
}
66+
67+
func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) BearerAuthorizer {
68+
return BearerAuthorizer{
69+
realm: realm,
70+
service: service,
71+
key: key,
72+
}
73+
}
74+
75+
// Authorize verifies whether the bearer token in the given Authorization header is valid, and whether it has sufficient
76+
// scope for the requested resource action. If an authorization error occurs (e.g. no token is given or the token has
77+
// insufficient scope), an AuthChallengeError is returned as the error.
78+
func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) error {
79+
challenge := &AuthChallengeError{
80+
realm: a.realm,
81+
service: a.service,
82+
resourceAction: requested,
83+
}
84+
85+
if header == "" {
86+
// if no bearer token is set in the authorization header, return the authentication challenge
87+
challenge.err = zerr.ErrNoBearerToken
88+
89+
return challenge
90+
}
91+
92+
signedString := bearerTokenMatch.ReplaceAllString(header, "$1")
93+
94+
token, err := jwt.ParseWithClaims(signedString, &ClaimsWithAccess{}, func(token *jwt.Token) (interface{}, error) {
95+
return a.key, nil
96+
}, jwt.WithValidMethods(a.allowedSigningAlgorithms()), jwt.WithIssuedAt())
97+
if err != nil {
98+
return fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err)
99+
}
100+
101+
if requested == nil {
102+
// the token is valid and no access is requested, so we do not have to validate the access claim
103+
return nil
104+
}
105+
106+
claims, ok := token.Claims.(*ClaimsWithAccess)
107+
if !ok {
108+
return fmt.Errorf("%w: invalid claims type", zerr.ErrInvalidBearerToken)
109+
}
110+
111+
// check whether the requested access is allowed by the scope of the token
112+
for _, allowed := range claims.Access {
113+
if allowed.Type != requested.Type {
114+
continue
115+
}
116+
117+
if allowed.Name != requested.Name {
118+
continue
119+
}
120+
121+
if !slices.Contains(allowed.Actions, requested.Action) {
122+
continue
123+
}
124+
125+
// requested action is allowed, so don't return an error
126+
return nil
127+
}
128+
129+
challenge.err = zerr.ErrInsufficientScope
130+
131+
return challenge
132+
}
133+
134+
func (a *BearerAuthorizer) allowedSigningAlgorithms() []string {
135+
return []string{"EdDSA", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"}
136+
}

0 commit comments

Comments
 (0)