diff --git a/common/error.go b/common/error.go index 880ea08..959f2d6 100644 --- a/common/error.go +++ b/common/error.go @@ -36,6 +36,7 @@ var ( ErrInvalidQueryParams = fmt.Errorf("invalid query parameters") ErrLowTrust = fmt.Errorf("token trust is lower than threshold") ErrPending = fmt.Errorf("application_status pending") + ErrNoCapabilities = NoCapabilitiesError{} ) type Http400Error struct { diff --git a/http/http_client.go b/http/http_client.go index 2761f9c..5d56575 100644 --- a/http/http_client.go +++ b/http/http_client.go @@ -349,7 +349,7 @@ func (c *HttpClient) Do(method string, url string, header http.Header, bbytes [] } switch res.StatusCode { - case http.StatusForbidden, http.StatusBadRequest, http.StatusNotFound: + case http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest, http.StatusNotFound: return rbytes, nil, false, common.NewError(err) } return rbytes, nil, true, common.NewError(err) diff --git a/http/multipart_test.go b/http/multipart_test.go index 775c1e7..ad66c4e 100644 --- a/http/multipart_test.go +++ b/http/multipart_test.go @@ -256,7 +256,7 @@ func TestCpeMiddleware(t *testing.T) { _, err = io.ReadAll(res.Body) assert.NilError(t, err) res.Body.Close() - assert.Equal(t, res.StatusCode, http.StatusForbidden) + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) // get a token token := server1.Generate(cpeMac, 86400) @@ -269,6 +269,20 @@ func TestCpeMiddleware(t *testing.T) { res.Body.Close() assert.Equal(t, res.StatusCode, http.StatusOK) + // XPC-43727 a real signed token for one cpeMac presented against a + // different mac's /config URL must be rejected as unauthorized, not + // treated as an authorization (403) failure + otherCpeMac := util.GenerateRandomCpeMac() + otherConfigUrl := fmt.Sprintf("/api/v1/device/%v/config", otherCpeMac) + req, err = http.NewRequest("GET", otherConfigUrl, nil) + assert.NilError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) + res = ExecuteRequest(req, router1).Result() + _, err = io.ReadAll(res.Body) + assert.NilError(t, err) + res.Body.Close() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) + // change the min trust to 1000 server1.SetMinTrust(1000) assert.Equal(t, 1000, server1.MinTrust()) diff --git a/http/response.go b/http/response.go index baebe01..e4895ac 100644 --- a/http/response.go +++ b/http/response.go @@ -142,7 +142,7 @@ func Error(w http.ResponseWriter, status int, err error) { // calling WriteHeader() multiple times will cause errors in common.HeaderContentType // ==> errors like 'superfluous response.WriteHeader call' in stderr switch status { - case http.StatusNoContent, http.StatusNotModified, http.StatusForbidden: + case http.StatusNoContent, http.StatusNotModified, http.StatusUnauthorized, http.StatusForbidden: w.WriteHeader(status) case http.StatusAccepted: SetAuditValue(w, "response", err) diff --git a/http/webconfig_server.go b/http/webconfig_server.go index 0da72bf..5277cd1 100644 --- a/http/webconfig_server.go +++ b/http/webconfig_server.go @@ -489,7 +489,8 @@ func (s *WebconfigServer) CpeMiddleware(next http.Handler) http.Handler { params := mux.Vars(r) mac, ok := params["mac"] if !ok { - Error(xw, http.StatusForbidden, nil) + err := *common.NewHttp400Error("missing mac") + Error(xw, http.StatusBadRequest, common.NewError(err)) return } mac = strings.ToUpper(mac) @@ -534,7 +535,11 @@ func (s *WebconfigServer) CpeMiddleware(next http.Handler) http.Handler { next.ServeHTTP(xw, r) } else { s.LogToken(xw, authorization, token, tokenErr) - Error(xw, http.StatusForbidden, nil) + if errors.Is(tokenErr, common.ErrNoCapabilities) || errors.Is(tokenErr, common.ErrLowTrust) { + Error(xw, http.StatusForbidden, nil) + } else { + Error(xw, http.StatusUnauthorized, nil) + } } } return http.HandlerFunc(fn) @@ -547,6 +552,7 @@ func (s *WebconfigServer) ApiMiddleware(next http.Handler) http.Handler { defer s.logRequestEnds(xw, r) isValid := false + var verifyErr error token := xw.Token() if len(token) > 0 { var kid string @@ -562,6 +568,7 @@ func (s *WebconfigServer) ApiMiddleware(next http.Handler) http.Handler { isValid = true log.WithFields(tfields).Debug("valid") } else { + verifyErr = err tfields["error"] = fmt.Sprintf("ApiMiddleware::VerifyApiToken() %v", err) log.WithFields(tfields).Debug("rejected") } @@ -572,7 +579,11 @@ func (s *WebconfigServer) ApiMiddleware(next http.Handler) http.Handler { if isValid { next.ServeHTTP(xw, r) } else { - Error(xw, http.StatusForbidden, nil) + if errors.Is(verifyErr, common.ErrNoCapabilities) { + Error(xw, http.StatusForbidden, nil) + } else { + Error(xw, http.StatusUnauthorized, nil) + } } } return http.HandlerFunc(fn) @@ -591,23 +602,31 @@ func (s *WebconfigServer) TestingCpeMiddleware(next http.Handler) http.Handler { } isValid := false + var verifyErr error if len(token) > 0 { params := mux.Vars(r) mac, ok := params["mac"] if !ok || len(mac) != 12 { - Error(xw, http.StatusForbidden, nil) + err := *common.NewHttp400Error("invalid mac") + Error(xw, http.StatusBadRequest, common.NewError(err)) return } - if ok, _, _, _ := s.VerifyCpeToken(token, strings.ToLower(mac)); ok { + if ok, _, _, err := s.VerifyCpeToken(token, strings.ToLower(mac)); ok { isValid = true + } else { + verifyErr = err } } if isValid { next.ServeHTTP(xw, r) } else { - Error(xw, http.StatusForbidden, nil) + if errors.Is(verifyErr, common.ErrNoCapabilities) { + Error(xw, http.StatusForbidden, nil) + } else { + Error(xw, http.StatusUnauthorized, nil) + } } } return http.HandlerFunc(fn) diff --git a/http/webconfig_server_test.go b/http/webconfig_server_test.go index 92d3e1a..6b611d4 100644 --- a/http/webconfig_server_test.go +++ b/http/webconfig_server_test.go @@ -18,10 +18,23 @@ package http import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" "net/http" + "net/http/httptest" "strings" "testing" + "time" + "github.com/MicahParks/keyfunc/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" + "github.com/rdkcentral/webconfig/common" + "github.com/rdkcentral/webconfig/security" log "github.com/sirupsen/logrus" "gotest.tools/assert" ) @@ -54,7 +67,7 @@ func TestConfigEndpointRequiresApiTokenWhenEnabled(t *testing.T) { req, err := http.NewRequest("GET", "/config", nil) assert.NilError(t, err) res := ExecuteRequest(req, router).Result() - assert.Equal(t, res.StatusCode, http.StatusForbidden) + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) } func TestApiMiddlewareSuppressesConfigRequestLogs(t *testing.T) { @@ -75,7 +88,7 @@ func TestApiMiddlewareSuppressesConfigRequestLogs(t *testing.T) { req, err := http.NewRequest("GET", "/config", nil) assert.NilError(t, err) res := ExecuteRequest(req, router).Result() - assert.Equal(t, res.StatusCode, http.StatusForbidden) + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) assert.Assert(t, !strings.Contains(logs.String(), "Request started")) assert.Assert(t, !strings.Contains(logs.String(), "Request finished")) @@ -85,11 +98,486 @@ func TestApiMiddlewareSuppressesConfigRequestLogs(t *testing.T) { res = ExecuteRequest(req, server.ApiMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))).Result() - assert.Equal(t, res.StatusCode, http.StatusForbidden) + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) assert.Assert(t, strings.Contains(logs.String(), "Request started")) assert.Assert(t, strings.Contains(logs.String(), "Request finished")) } +func TestApiMiddlewareReturnsUnauthorizedForAuthenticationFailures(t *testing.T) { + tests := []struct { + name string + auth string + err error + }{ + {name: "missing token"}, + {name: "malformed authorization", auth: "Basic credentials"}, + {name: "bearer with no token", auth: "Bearer"}, + {name: "bearer with multiple parts", auth: "Bearer a b"}, + {name: "malformed token", auth: "Bearer malformed", err: fmt.Errorf("malformed token")}, + {name: "invalid token", auth: "Bearer invalid", err: fmt.Errorf("invalid token")}, + {name: "expired token", auth: "Bearer expired", err: fmt.Errorf("token is expired")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, tt.err + }) + + req, err := http.NewRequest("GET", "/", nil) + assert.NilError(t, err) + if tt.auth != "" { + req.Header.Set("Authorization", tt.auth) + } + res := ExecuteRequest(req, server.ApiMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authentication failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) + }) + } +} + +func TestApiMiddlewareReturnsForbiddenForInsufficientCapabilities(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, common.ErrNoCapabilities + }) + + req, err := http.NewRequest("GET", "/", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer authenticated-but-not-capable") + res := ExecuteRequest(req, server.ApiMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authorization failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusForbidden) +} + +// XPC-43727 an actually malformed JWT (not a mocked verifier error) must +// still be classified as 401 through the real TokenManager.VerifyToken path +func TestApiMiddlewareRejectsActualMalformedJwt(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + + req, err := http.NewRequest("GET", "/", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer this-is-not-a-jwt") + res := ExecuteRequest(req, server.ApiMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("malformed jwt reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +func newJwksHttpTestFixture(t *testing.T) (*rsa.PrivateKey, *keyfunc.JWKS) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NilError(t, err) + + const kid = "jwks-http-test-key" + b64 := func(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) + } + eBytes := big.NewInt(int64(privateKey.PublicKey.E)).Bytes() + jwkJSON := fmt.Sprintf(`{"keys":[{"kty":"RSA","kid":%q,"alg":"RS256","n":%q,"e":%q}]}`, + kid, b64(privateKey.PublicKey.N.Bytes()), b64(eBytes)) + + jwks, err := keyfunc.NewJSON(json.RawMessage(jwkJSON)) + assert.NilError(t, err) + return privateKey, jwks +} + +func signJwksHttpTestToken(t *testing.T, privateKey *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "jwks-http-test-key" + tokenString, err := token.SignedString(privateKey) + assert.NilError(t, err) + return tokenString +} + +// XPC-43727 exercise the ApiMiddleware route actually used when JWKS is +// enabled (server.JwksManager / errors.Is classification via VerifyApiToken), +// not just the underlying JwksManager in isolation +func TestApiMiddlewareJwksEnabledRoute(t *testing.T) { + privateKey, jwks := newJwksHttpTestFixture(t) + utcnow := time.Now() + + tests := []struct { + name string + token string + status int + }{ + { + name: "valid capability", + token: signJwksHttpTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"webconfig:all"}, + "exp": utcnow.Add(time.Hour).Unix(), + }), + status: http.StatusOK, + }, + { + name: "missing capability", + token: signJwksHttpTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"some:other:capability"}, + "exp": utcnow.Add(time.Hour).Unix(), + }), + status: http.StatusForbidden, + }, + { + name: "expired token", + token: signJwksHttpTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"webconfig:all"}, + "exp": utcnow.Add(-time.Hour).Unix(), + }), + status: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.JwksManager = security.NewJwksManagerFromJWKS(jwks, []string{"webconfig:all"}) + server.SetJwksEnabled(true) + + req, err := http.NewRequest("GET", "/", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", tt.token)) + res := ExecuteRequest(req, server.ApiMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tt.status != http.StatusOK { + t.Fatal("authentication/authorization failure reached the next handler") + } + w.WriteHeader(http.StatusOK) + }))).Result() + assert.Equal(t, res.StatusCode, tt.status) + }) + } +} + +// XPC-43727 an invalid-signature token must return 401 through the JWKS +// route, never misclassified as 403 +func TestApiMiddlewareJwksEnabledRouteRejectsInvalidSignature(t *testing.T) { + privateKey, jwks := newJwksHttpTestFixture(t) + utcnow := time.Now() + token := signJwksHttpTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"webconfig:all"}, + "exp": utcnow.Add(time.Hour).Unix(), + }) + + parts := []rune(token) + lastDot := -1 + for i, r := range parts { + if r == '.' { + lastDot = i + } + } + assert.Assert(t, lastDot >= 0 && lastDot+2 < len(parts)) + mid := lastDot + 1 + (len(parts)-lastDot-1)/2 + if parts[mid] == 'A' { + parts[mid] = 'B' + } else { + parts[mid] = 'A' + } + tampered := string(parts) + + server := NewWebconfigServer(sc, true) + server.JwksManager = security.NewJwksManagerFromJWKS(jwks, []string{"webconfig:all"}) + server.SetJwksEnabled(true) + + req, err := http.NewRequest("GET", "/", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", tampered)) + res := ExecuteRequest(req, server.ApiMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("invalid signature reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +func TestCpeMiddlewareReturnsUnauthorizedForInvalidToken(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, fmt.Errorf("invalid token") + }) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer invalid") + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authentication failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +func TestCpeMiddlewareReturnsForbiddenForInsufficientCapabilities(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, common.ErrNoCapabilities + }) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer authenticated-but-not-capable") + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authorization failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusForbidden) +} + +func TestCpeMiddlewareReturnsForbiddenForLowTrust(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.SetMinTrust(1000) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return true, "comcast", 0, nil + }) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer authenticated-but-low-trust") + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authorization failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusForbidden) +} + +// XPC-43727 every class of authentication failure (malformed header, +// malformed/invalid/expired token, mac mismatch) must return 401, never 403. +func TestCpeMiddlewareReturnsUnauthorizedForAuthenticationFailures(t *testing.T) { + tests := []struct { + name string + auth string + err error + }{ + {name: "missing token"}, + {name: "malformed authorization", auth: "Basic credentials"}, + {name: "bearer with no token", auth: "Bearer"}, + {name: "bearer with multiple parts", auth: "Bearer a b"}, + {name: "malformed token", auth: "Bearer malformed", err: fmt.Errorf("malformed token")}, + {name: "invalid signature", auth: "Bearer invalid", err: fmt.Errorf("token signature is invalid")}, + {name: "expired token", auth: "Bearer expired", err: fmt.Errorf("token is expired")}, + {name: "mac mismatch", auth: "Bearer mismatched-mac", err: fmt.Errorf("mac in token does not match claims")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, tt.err + }) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + if tt.auth != "" { + req.Header.Set("Authorization", tt.auth) + } + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authentication failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) + }) + } +} + +func TestCpeMiddlewareReturnsBadRequestForMissingMacRouteVar(t *testing.T) { + server := NewWebconfigServer(sc, true) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer irrelevant") + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("missing route var reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusBadRequest) +} + +// XPC-43727 an actually malformed JWT (not a mocked verifier error) must +// still be classified as 401 through the real TokenManager.VerifyToken path +func TestCpeMiddlewareRejectsActualMalformedJwt(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer this-is-not-a-jwt") + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("malformed jwt reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +// XPC-43727 a real signed token for one mac presented against a different +// mac must be rejected as 401, using an in-memory generated key pair so this +// assertion is isolated from external key files/db setup (unlike the +// equivalent case in multipart_test.go's TestCpeMiddleware) +func TestCpeMiddlewareRejectsRealSignedTokenWithWrongMac(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NilError(t, err) + + const kid = "cpe-wrong-mac-test-key" + decodeKeys := map[string]*rsa.PublicKey{kid: &privateKey.PublicKey} + + sign := func(mac string) string { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "mac": strings.ToLower(mac), + "exp": time.Now().Add(time.Hour).Unix(), + }) + token.Header["kid"] = kid + tokenString, err := token.SignedString(privateKey) + assert.NilError(t, err) + return tokenString + } + token := sign("AABBCCDDEEFF") + + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, validKids []string, requiredCapabilities []string, vargs ...string) (bool, string, int, error) { + return security.VerifyToken(decodeKeys, []string{kid}, requiredCapabilities, vargs...) + }) + + req, err := http.NewRequest("GET", "/api/v1/device/112233445566/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) + req = mux.SetURLVars(req, map[string]string{"mac": "112233445566"}) + res := ExecuteRequest(req, server.CpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("wrong-mac token reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +func TestTestingCpeMiddlewareReturnsUnauthorizedForMissingToken(t *testing.T) { + server := NewWebconfigServer(sc, true) + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.TestingCpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authentication failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +// XPC-43727 TestingCpeMiddleware independently parses the Authorization +// header and calls VerifyCpeToken directly; verify it classifies the same +// authentication failure classes as CpeMiddleware, despite the separate +// implementation. +func TestTestingCpeMiddlewareReturnsUnauthorizedForAuthenticationFailures(t *testing.T) { + tests := []struct { + name string + auth string + err error + }{ + {name: "malformed authorization", auth: "Basic credentials"}, + {name: "bearer with no token", auth: "Bearer"}, + {name: "bearer with multiple parts", auth: "Bearer a b"}, + {name: "malformed token", auth: "Bearer malformed", err: fmt.Errorf("malformed token")}, + {name: "invalid signature", auth: "Bearer invalid", err: fmt.Errorf("token signature is invalid")}, + {name: "expired token", auth: "Bearer expired", err: fmt.Errorf("token is expired")}, + {name: "mac mismatch", auth: "Bearer mismatched-mac", err: fmt.Errorf("mac in token does not match claims")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, tt.err + }) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", tt.auth) + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.TestingCpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authentication failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) + }) + } +} + +// XPC-43727 an actually malformed JWT (not a mocked verifier error) must +// still be classified as 401 through the real TokenManager.VerifyToken path +func TestTestingCpeMiddlewareRejectsActualMalformedJwt(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer this-is-not-a-jwt") + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.TestingCpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("malformed jwt reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusUnauthorized) +} + +// XPC-43727 TestingCpeMiddleware also supports a genuine post-authentication +// authorization failure, distinct from an authentication failure: 403. +func TestTestingCpeMiddlewareReturnsForbiddenForInsufficientCapabilities(t *testing.T) { + server := NewWebconfigServer(sc, true) + server.TokenManager = security.NewTokenManager(sc.Config) + server.TokenManager.SetVerifyFunc(func(_ map[string]*rsa.PublicKey, _ []string, _ []string, _ ...string) (bool, string, int, error) { + return false, "", 0, common.ErrNoCapabilities + }) + + req, err := http.NewRequest("GET", "/api/v1/device/AABBCCDDEEFF/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer authenticated-but-not-capable") + req = mux.SetURLVars(req, map[string]string{"mac": "AABBCCDDEEFF"}) + res := ExecuteRequest(req, server.TestingCpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("authorization failure reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusForbidden) +} + +// XPC-43727 TestingCpeMiddleware independently validates the mac route var +// (exact length 12), unlike CpeMiddleware's ValidateMac; verify a malformed +// or missing mac is classified as 400, not 401, when a Bearer token is +// present. +func TestTestingCpeMiddlewareReturnsBadRequestForInvalidMac(t *testing.T) { + tests := []struct { + name string + vars map[string]string + }{ + {name: "mac too short", vars: map[string]string{"mac": "AABBCC"}}, + {name: "missing mac var", vars: map[string]string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewWebconfigServer(sc, true) + + req, err := http.NewRequest("GET", "/api/v1/device/x/config", nil) + assert.NilError(t, err) + req.Header.Set("Authorization", "Bearer irrelevant") + req = mux.SetURLVars(req, tt.vars) + res := ExecuteRequest(req, server.TestingCpeMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("invalid mac reached the next handler") + }))).Result() + assert.Equal(t, res.StatusCode, http.StatusBadRequest) + }) + } +} + +func TestErrorPreservesUnauthorizedResponse(t *testing.T) { + recorder := httptest.NewRecorder() + + Error(recorder, http.StatusUnauthorized, nil) + + assert.Equal(t, recorder.Code, http.StatusUnauthorized) + assert.Equal(t, recorder.Body.Len(), 0) +} + func TestWebconfigServerSetterGetter(t *testing.T) { server := NewWebconfigServer(sc, true) diff --git a/security/jwks_manager.go b/security/jwks_manager.go index 232fff3..60f8ffd 100644 --- a/security/jwks_manager.go +++ b/security/jwks_manager.go @@ -14,7 +14,7 @@ * limitations under the License. * * SPDX-License-Identifier: Apache-2.0 -*/ + */ package security import ( @@ -23,10 +23,10 @@ import ( "time" "github.com/MicahParks/keyfunc/v2" - "github.com/rdkcentral/webconfig/common" - "github.com/rdkcentral/webconfig/util" "github.com/go-akka/configuration" "github.com/golang-jwt/jwt/v5" + "github.com/rdkcentral/webconfig/common" + "github.com/rdkcentral/webconfig/util" log "github.com/sirupsen/logrus" ) @@ -80,7 +80,10 @@ func (m *JwksManager) VerifyApiToken(tokenStr string) (bool, error) { if itf, ok := mclaims["capabilities"]; ok { if iitfs, ok := itf.([]interface{}); ok { for _, iitf := range iitfs { - ss := iitf.(string) + ss, ok := iitf.(string) + if !ok { + continue + } if util.Contains(m.apiCapabilities, ss) { return true, nil } @@ -91,6 +94,17 @@ func (m *JwksManager) VerifyApiToken(tokenStr string) (bool, error) { return false, common.NoCapabilitiesError{} } +// NewJwksManagerFromJWKS builds a JwksManager from an already-constructed +// JWKS, bypassing the network fetch in NewJwksManager. Used by tests that +// need a self-contained, signature-verified JwksManager without a live JWKS +// endpoint. +func NewJwksManagerFromJWKS(jwks *keyfunc.JWKS, apiCapabilities []string) *JwksManager { + return &JwksManager{ + jwks: jwks, + apiCapabilities: apiCapabilities, + } +} + func LogRefreshError(err error) { fields := log.Fields{ "logger": "codebig", diff --git a/security/jwks_manager_test.go b/security/jwks_manager_test.go new file mode 100644 index 0000000..bc2ce2a --- /dev/null +++ b/security/jwks_manager_test.go @@ -0,0 +1,171 @@ +/** +* Copyright 2021 Comcast Cable Communications Management, LLC +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +* SPDX-License-Identifier: Apache-2.0 + */ +package security + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/MicahParks/keyfunc/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/rdkcentral/webconfig/common" + "gotest.tools/assert" +) + +// XPC-43727 real JWKS-backed fixtures: a self-contained RSA key pair and a +// keyfunc.JWKS built from it via keyfunc.NewJSON(), so VerifyApiToken can be +// exercised end-to-end (real signature verification) without any HTTP/JWKS +// server dependency. +const jwksTestKid = "jwks-test-key" + +func newJwksTestFixture(t *testing.T) (*rsa.PrivateKey, *keyfunc.JWKS) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NilError(t, err) + + b64 := func(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) + } + eBytes := big.NewInt(int64(privateKey.PublicKey.E)).Bytes() + + jwkJSON := fmt.Sprintf(`{"keys":[{"kty":"RSA","kid":%q,"alg":"RS256","n":%q,"e":%q}]}`, + jwksTestKid, b64(privateKey.PublicKey.N.Bytes()), b64(eBytes)) + + jwks, err := keyfunc.NewJSON(json.RawMessage(jwkJSON)) + assert.NilError(t, err) + + return privateKey, jwks +} + +func signJwksTestToken(t *testing.T, privateKey *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = jwksTestKid + tokenString, err := token.SignedString(privateKey) + assert.NilError(t, err) + return tokenString +} + +func TestJwksManagerVerifyApiTokenValidCapability(t *testing.T) { + privateKey, jwks := newJwksTestFixture(t) + m := &JwksManager{jwks: jwks, apiCapabilities: []string{"webconfig:all"}} + + utcnow := time.Now() + token := signJwksTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"webconfig:all"}, + "exp": utcnow.Add(time.Hour).Unix(), + }) + + ok, err := m.VerifyApiToken(token) + assert.NilError(t, err) + assert.Assert(t, ok) +} + +func TestJwksManagerVerifyApiTokenMissingCapability(t *testing.T) { + privateKey, jwks := newJwksTestFixture(t) + m := &JwksManager{jwks: jwks, apiCapabilities: []string{"webconfig:all"}} + + utcnow := time.Now() + token := signJwksTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"some:other:capability"}, + "exp": utcnow.Add(time.Hour).Unix(), + }) + + ok, err := m.VerifyApiToken(token) + assert.Assert(t, !ok) + var noCapsErr common.NoCapabilitiesError + assert.Assert(t, errors.As(err, &noCapsErr)) +} + +// XPC-43727 a non-string entry in the capabilities claim must not panic; +// it must be treated as not-capable, same as an absent/mismatched capability. +func TestJwksManagerVerifyApiTokenMalformedCapabilityDoesNotPanic(t *testing.T) { + privateKey, jwks := newJwksTestFixture(t) + m := &JwksManager{jwks: jwks, apiCapabilities: []string{"webconfig:all"}} + + utcnow := time.Now() + token := signJwksTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{12345, "some:other:capability"}, + "exp": utcnow.Add(time.Hour).Unix(), + }) + + ok, err := m.VerifyApiToken(token) + assert.Assert(t, !ok) + var noCapsErr common.NoCapabilitiesError + assert.Assert(t, errors.As(err, &noCapsErr)) +} + +func TestJwksManagerVerifyApiTokenInvalidSignature(t *testing.T) { + privateKey, jwks := newJwksTestFixture(t) + m := &JwksManager{jwks: jwks, apiCapabilities: []string{"webconfig:all"}} + + utcnow := time.Now() + token := signJwksTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"webconfig:all"}, + "exp": utcnow.Add(time.Hour).Unix(), + }) + + // tamper a character in the middle of the signature segment; avoid the + // last char, whose low bits may be unused by base64url padding + parts := []rune(token) + // find the position of the last '.' to locate the signature segment + lastDot := -1 + for i, r := range parts { + if r == '.' { + lastDot = i + } + } + assert.Assert(t, lastDot >= 0 && lastDot+2 < len(parts)) + mid := lastDot + 1 + (len(parts)-lastDot-1)/2 + if parts[mid] == 'A' { + parts[mid] = 'B' + } else { + parts[mid] = 'A' + } + tampered := string(parts) + + ok, err := m.VerifyApiToken(tampered) + assert.Assert(t, !ok) + var noCapsErr common.NoCapabilitiesError + assert.Assert(t, !errors.As(err, &noCapsErr)) +} + +func TestJwksManagerVerifyApiTokenExpiredToken(t *testing.T) { + privateKey, jwks := newJwksTestFixture(t) + m := &JwksManager{jwks: jwks, apiCapabilities: []string{"webconfig:all"}} + + utcnow := time.Now() + token := signJwksTestToken(t, privateKey, jwt.MapClaims{ + "capabilities": []interface{}{"webconfig:all"}, + "exp": utcnow.Add(-time.Hour).Unix(), + }) + + ok, err := m.VerifyApiToken(token) + assert.Assert(t, !ok) + var noCapsErr common.NoCapabilitiesError + assert.Assert(t, !errors.As(err, &noCapsErr)) +} diff --git a/security/token.go b/security/token.go index 47270a4..7420a63 100644 --- a/security/token.go +++ b/security/token.go @@ -301,55 +301,33 @@ func VerifyToken(decodeKeys map[string]*rsa.PublicKey, validKids []string, requi parser := &jwt.Parser{} - // this is the claims before the token is verified by the public key + // this is only used to read the kid from the header; claims are not + // trusted until the token is verified against the public key below uclaims := jwt.MapClaims{} - if token, _, err := parser.ParseUnverified(tokenString, uclaims); err == nil { - // check kid - rawkid, ok := token.Header["kid"] - if !ok { - return false, "", trust, common.NewError(fmt.Errorf("missing kid in token")) - } - kid, ok = rawkid.(string) - if !ok { - return false, "", trust, common.NewError(fmt.Errorf("error in reading kid from header")) - } + token, _, err := parser.ParseUnverified(tokenString, uclaims) + if err != nil { + return false, "", trust, common.NewError(err) + } - ok = false - for _, k := range validKids { - if kid == k { - ok = true - break - } - } - if !ok { - return false, "", trust, common.NewError(fmt.Errorf("token kid=%v, not in validKids=%v", kid, validKids)) - } + // check kid + rawkid, ok := token.Header["kid"] + if !ok { + return false, "", trust, common.NewError(fmt.Errorf("missing kid in token")) + } + kid, ok = rawkid.(string) + if !ok { + return false, "", trust, common.NewError(fmt.Errorf("error in reading kid from header")) + } - // check capabilities, if requiredCapabilities is nonempty - if len(requiredCapabilities) > 0 { - isCapable := false - if capitfs, ok := uclaims["capabilities"]; ok { - capvalues, ok1 := capitfs.([]interface{}) - if ok1 { - for _, capvalue := range capvalues { - for _, rc := range requiredCapabilities { - if rc == capvalue { - isCapable = true - break - } - } - if isCapable { - break - } - } - } - } - if !isCapable { - return false, "", trust, common.NewError(fmt.Errorf("token without proper capabilities")) - } + ok = false + for _, k := range validKids { + if kid == k { + ok = true + break } - } else { - return false, "", trust, common.NewError(err) + } + if !ok { + return false, "", trust, common.NewError(fmt.Errorf("token kid=%v, not in validKids=%v", kid, validKids)) } decodeKey, ok := decodeKeys[kid] @@ -357,11 +335,38 @@ func VerifyToken(decodeKeys map[string]*rsa.PublicKey, validKids []string, requi return false, "", trust, common.NewError(fmt.Errorf("key object missing, kid=%v", kid)) } + // verify signature and registered claims (exp, nbf, etc) before trusting + // anything in the token, including capabilities claims := jwt.MapClaims{} if _, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { return decodeKey, nil }); err != nil { return false, "", trust, common.NewError(err) } + // check capabilities, if requiredCapabilities is nonempty + // this must run only after the token has been cryptographically verified + if len(requiredCapabilities) > 0 { + isCapable := false + if capitfs, ok := claims["capabilities"]; ok { + capvalues, ok1 := capitfs.([]interface{}) + if ok1 { + for _, capvalue := range capvalues { + for _, rc := range requiredCapabilities { + if rc == capvalue { + isCapable = true + break + } + } + if isCapable { + break + } + } + } + } + if !isCapable { + return false, "", trust, common.NewError(common.ErrNoCapabilities) + } + } + if len(vargs) > 1 { mac := vargs[1] // mac must match @@ -383,7 +388,11 @@ func VerifyToken(decodeKeys map[string]*rsa.PublicKey, validKids []string, requi // parse partner partner := "comcast" if itf, ok := claims["partner-id"]; ok { - partner = itf.(string) + partnerStr, ok := itf.(string) + if !ok { + return false, "", trust, common.NewError(fmt.Errorf("partner-id claim is not a string")) + } + partner = partnerStr } if itf, ok := claims["trust"]; ok { diff --git a/security/token_test.go b/security/token_test.go index 6f73aa6..ae7506b 100644 --- a/security/token_test.go +++ b/security/token_test.go @@ -22,7 +22,9 @@ import ( "os" "strings" "testing" + "time" + "github.com/golang-jwt/jwt/v5" "github.com/rdkcentral/webconfig/common" "github.com/rdkcentral/webconfig/util" "gotest.tools/assert" @@ -86,3 +88,131 @@ func TestTokenValidation(t *testing.T) { assert.Equal(t, parsedPartner, partner1) assert.Equal(t, trust, 500) } + +// XPC-42727 a token with an invalid signature must fail as an authentication +// error, never misclassified as ErrNoCapabilities, even when its (untrusted) +// claims lack the required capability +func TestVerifyTokenRejectsInvalidSignatureBeforeCapabilities(t *testing.T) { + if tokenManager == nil { + t.Skip("webconfig.jwt.enabled = false") + } + + cpeMac := util.GenerateRandomCpeMac() + token := tokenManager.Generate(strings.ToLower(cpeMac), 86400) + + parts := strings.Split(token, ".") + assert.Equal(t, len(parts), 3) + // flip a character in the middle of the signature to invalidate it; + // avoid the last char, whose low bits may be unused by base64url padding + sig := []rune(parts[2]) + mid := len(sig) / 2 + if sig[mid] == 'A' { + sig[mid] = 'B' + } else { + sig[mid] = 'A' + } + tampered := parts[0] + "." + parts[1] + "." + string(sig) + + requiredCapabilities := []string{"capability-not-present-in-token"} + ok, _, _, verr := VerifyToken(tokenManager.decodeKeys, tokenManager.cpeKids, requiredCapabilities, tampered, cpeMac) + assert.Assert(t, !ok) + assert.Assert(t, !errors.Is(verr, common.ErrNoCapabilities)) +} + +// XPC-43727 a validly signed token whose partner-id claim is not a string +// must fail as a controlled authentication error, never panic +func TestVerifyTokenRejectsMalformedPartnerIdClaim(t *testing.T) { + if tokenManager == nil { + t.Skip("webconfig.jwt.enabled = false") + } + + cpeMac := util.GenerateRandomCpeMac() + utcnow := time.Now() + claims := jwt.MapClaims{ + "mac": strings.ToLower(cpeMac), + "partner-id": 12345, + "trust": 1000, + "exp": utcnow.Add(time.Hour).Unix(), + } + method := jwt.GetSigningMethod("RS256") + token := jwt.NewWithClaims(method, claims) + token.Header["kid"] = EncodingKeyId + tokenString, err := token.SignedString(tokenManager.encodeKey) + assert.NilError(t, err) + + ok, _, _, verr := VerifyToken(tokenManager.decodeKeys, tokenManager.cpeKids, nil, tokenString, cpeMac) + assert.Assert(t, !ok) + assert.ErrorContains(t, verr, "partner-id") +} + +// XPC-43727 if an operator configures webconfig.jwt.cpe_token.capabilities, +// VerifyToken enforces it correctly for a real signed CPE token: a matching +// capability succeeds, a non-matching capability fails as ErrNoCapabilities. +// The shipped sample config leaves cpe_token.capabilities empty by design +// (CPE authorization is governed by trust, not capabilities); this proves the +// capability-check code path itself works without changing that +// configuration. +func TestVerifyTokenEnforcesCpeCapabilitiesWhenConfigured(t *testing.T) { + if tokenManager == nil { + t.Skip("webconfig.jwt.enabled = false") + } + + cpeMac := util.GenerateRandomCpeMac() + sign := func(capabilities []string) string { + claims := jwt.MapClaims{ + "mac": strings.ToLower(cpeMac), + "capabilities": capabilities, + "exp": time.Now().Add(time.Hour).Unix(), + } + token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims) + token.Header["kid"] = EncodingKeyId + tokenString, err := token.SignedString(tokenManager.encodeKey) + assert.NilError(t, err) + return tokenString + } + + requiredCapabilities := []string{"cpe:config:read"} + + matching := sign([]string{"cpe:config:read"}) + ok, _, _, err := VerifyToken(tokenManager.decodeKeys, tokenManager.cpeKids, requiredCapabilities, matching, cpeMac) + assert.NilError(t, err) + assert.Assert(t, ok) + + nonMatching := sign([]string{"cpe:other"}) + ok, _, _, err = VerifyToken(tokenManager.decodeKeys, tokenManager.cpeKids, requiredCapabilities, nonMatching, cpeMac) + assert.Assert(t, !ok) + assert.Assert(t, errors.Is(err, common.ErrNoCapabilities)) +} + +// XPC-42727 an expired token must fail as an authentication error, never +// misclassified as ErrNoCapabilities, even when its (untrusted) claims lack +// the required capability +func TestVerifyTokenRejectsExpiredTokenBeforeCapabilities(t *testing.T) { + if tokenManager == nil { + t.Skip("webconfig.jwt.enabled = false") + } + + cpeMac := util.GenerateRandomCpeMac() + utcnow := time.Now() + claims := ThemisClaims{ + KeyId: EncodingKeyId, + Mac: strings.ToLower(cpeMac), + PartnerId: "comcast", + Trust: 1000, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(utcnow.Add(-1 * time.Hour)), + IssuedAt: jwt.NewNumericDate(utcnow.Add(-2 * time.Hour)), + NotBefore: jwt.NewNumericDate(utcnow.Add(-2 * time.Hour)), + }, + } + method := jwt.GetSigningMethod("RS256") + token := jwt.NewWithClaims(method, claims) + token.Header["kid"] = EncodingKeyId + tokenString, err := token.SignedString(tokenManager.encodeKey) + assert.NilError(t, err) + + requiredCapabilities := []string{"capability-not-present-in-token"} + ok, _, _, verr := VerifyToken(tokenManager.decodeKeys, tokenManager.cpeKids, requiredCapabilities, tokenString, cpeMac) + assert.Assert(t, !ok) + assert.Assert(t, !errors.Is(verr, common.ErrNoCapabilities)) +}