Skip to content

Commit

Permalink
Merge pull request #383 from tranchitella/men-5676
Browse files Browse the repository at this point in the history
feat: support for Ed25519 server keys for signing the JWT tokens
  • Loading branch information
tranchitella authored Oct 25, 2023
2 parents 7801cd3 + 030b450 commit 093aabe
Show file tree
Hide file tree
Showing 25 changed files with 1,015 additions and 688 deletions.
4 changes: 2 additions & 2 deletions api/http/api_useradm.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ var (
type UserAdmApiHandlers struct {
userAdm useradm.App
db store.DataStore
jwth *jwt.JWTHandlerRS256
jwth jwt.Handler
config Config
}

Expand All @@ -89,7 +89,7 @@ type Config struct {
func NewUserAdmApiHandlers(
userAdm useradm.App,
db store.DataStore,
jwth *jwt.JWTHandlerRS256,
jwth jwt.Handler,
config Config,
) ApiHandler {
return &UserAdmApiHandlers{
Expand Down
15 changes: 11 additions & 4 deletions api/http/api_useradm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ package http
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
Expand All @@ -41,7 +44,6 @@ import (
"github.com/mendersoftware/useradm/authz"
mauthz "github.com/mendersoftware/useradm/authz/mocks"
"github.com/mendersoftware/useradm/jwt"
"github.com/mendersoftware/useradm/keys"
"github.com/mendersoftware/useradm/model"
"github.com/mendersoftware/useradm/store"
mstore "github.com/mendersoftware/useradm/store/mocks"
Expand Down Expand Up @@ -856,11 +858,16 @@ func TestUpdateUser(t *testing.T) {

func makeMockApiHandler(t *testing.T, uadm useradm.App, db store.DataStore) http.Handler {
// JWT handler
privkey, err := keys.LoadRSAPrivate("../../crypto/private.pem")
if !assert.NoError(t, err) {
data, err := os.ReadFile("../../crypto/private.pem")
if err != nil {
t.Fatalf("faied to load private key: %v", err)
}
jwth := jwt.NewJWTHandlerRS256(privkey, nil)

block, _ := pem.Decode(data)
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
assert.NoError(t, err)

jwth := jwt.NewJWTHandlerRS256(key)

// API handler
handlers := NewUserAdmApiHandlers(uadm, db, jwth, Config{})
Expand Down
21 changes: 11 additions & 10 deletions authz/authz.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// Copyright 2021 Northern.tech AS
// Copyright 2023 Northern.tech AS
//
// 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
// 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
// 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.
// 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.
package authz

import (
Expand All @@ -30,6 +30,7 @@ var (

// Authorizer defines the interface for checking the permissions of a given user(token) vs an action
// on a resource.
//
//go:generate ../utils/mockgen.sh
type Authorizer interface {
// Authorize checks if the given user (identified by token) has permissions to an action on a
Expand Down
30 changes: 17 additions & 13 deletions authz/middleware.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// Copyright 2021 Northern.tech AS
// Copyright 2023 Northern.tech AS
//
// 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
// 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
// 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.
// 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.
package authz

import (
Expand All @@ -33,9 +33,10 @@ const (
// It retrieves the token + requested resource and action, and delegates the authz check to an
// Authorizer.
type AuthzMiddleware struct {
Authz Authorizer
ResFunc ResourceActionExtractor
JWTHandler jwt.Handler
Authz Authorizer
ResFunc ResourceActionExtractor
JWTHandler jwt.Handler
JWTFallbackHandler jwt.Handler
}

// Action combines info about the requested resourd + http method.
Expand All @@ -61,6 +62,9 @@ func (mw *AuthzMiddleware) MiddlewareFunc(h rest.HandlerFunc) rest.HandlerFunc {

// parse token, insert into env
token, err := mw.JWTHandler.FromJWT(tokstr)
if err != nil && mw.JWTFallbackHandler != nil {
token, err = mw.JWTFallbackHandler.FromJWT(tokstr)
}
if err != nil {
rest_utils.RestErrWithLog(w, r, l, ErrAuthzTokenInvalid, http.StatusUnauthorized)
return
Expand Down
9 changes: 5 additions & 4 deletions authz/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"io/ioutil"
"io"
"net/http"
"os"
"testing"
"time"

Expand Down Expand Up @@ -304,7 +305,7 @@ func TestAuthzMiddleware(t *testing.T) {
api := rest.NewApi()
api.Use(
&requestlog.RequestLogMiddleware{
BaseLogger: &logrus.Logger{Out: ioutil.Discard},
BaseLogger: &logrus.Logger{Out: io.Discard},
},
&requestid.RequestIdMiddleware{},
)
Expand All @@ -330,7 +331,7 @@ func TestAuthzMiddleware(t *testing.T) {

//finish setting up the middleware
privkey := loadPrivKey("../crypto/private.pem", t)
jwth := jwt.NewJWTHandlerRS256(privkey, nil)
jwth := jwt.NewJWTHandlerRS256(privkey)
mw := AuthzMiddleware{
Authz: a,
ResFunc: resfunc,
Expand Down Expand Up @@ -365,7 +366,7 @@ func restError(status string) map[string]interface{} {
}

func loadPrivKey(path string, t *testing.T) *rsa.PrivateKey {
pem_data, err := ioutil.ReadFile(path)
pem_data, err := os.ReadFile(path)
if err != nil {
t.FailNow()
}
Expand Down
6 changes: 3 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const (
SettingMiddleware = "middleware"
SettingMiddlewareDefault = "prod"

SettingPrivKeyPath = "server_priv_key_path"
SettingPrivKeyPathDefault = "/etc/useradm/rsa/private.pem"
SettingServerPrivKeyPath = "server_priv_key_path"
SettingServerPrivKeyPathDefault = "/etc/useradm/rsa/private.pem"

SettingServerFallbackPrivKeyPath = "server_fallback_priv_key_path"
SettingServerFallbackPrivKeyPathDefault = ""
Expand Down Expand Up @@ -72,7 +72,7 @@ var (
ConfigDefaults = []config.Default{
{Key: SettingListen, Value: SettingListenDefault},
{Key: SettingMiddleware, Value: SettingMiddlewareDefault},
{Key: SettingPrivKeyPath, Value: SettingPrivKeyPathDefault},
{Key: SettingServerPrivKeyPath, Value: SettingServerPrivKeyPathDefault},
{Key: SettingServerFallbackPrivKeyPath, Value: SettingServerFallbackPrivKeyPathDefault},
{Key: SettingJWTIssuer, Value: SettingJWTIssuerDefault},
{Key: SettingJWTExpirationTimeout, Value: SettingJWTExpirationTimeoutDefault},
Expand Down
93 changes: 33 additions & 60 deletions jwt/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
package jwt

import (
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"

jwtgo "github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
)

Expand All @@ -25,77 +28,47 @@ var (
ErrTokenInvalid = errors.New("jwt: token invalid")
)

// JWTHandler jwt generator/verifier
const (
pemHeaderPKCS1 = "RSA PRIVATE KEY"
pemHeaderPKCS8 = "PRIVATE KEY"
)

// Handler jwt generator/verifier
//
//go:generate ../utils/mockgen.sh
type Handler interface {
ToJWT(t *Token) (string, error)
// FromJWT parses the token and does basic validity checks (Claims.Valid().
// FromJWT parses the token and does basic validity checks (Claims.Valid()).
// returns:
// ErrTokenExpired when the token is valid but expired
// ErrTokenInvalid when the token is invalid (malformed, missing required claims, etc.)
FromJWT(string) (*Token, error)
}

// JWTHandlerRS256 is an RS256-specific JWTHandler
type JWTHandlerRS256 struct {
privKey *rsa.PrivateKey
fallbackPrivKey *rsa.PrivateKey
}

func NewJWTHandlerRS256(privKey *rsa.PrivateKey, fallbackPrivKey *rsa.PrivateKey) *JWTHandlerRS256 {
return &JWTHandlerRS256{
privKey: privKey,
fallbackPrivKey: fallbackPrivKey,
func NewJWTHandler(privateKeyPath string) (Handler, error) {
priv, err := os.ReadFile(privateKeyPath)
block, _ := pem.Decode(priv)
if block == nil {
return nil, errors.Wrap(err, "failed to read private key")
}
}

func (j *JWTHandlerRS256) ToJWT(token *Token) (string, error) {
//generate
jt := jwtgo.NewWithClaims(jwtgo.SigningMethodRS256, &token.Claims)

//sign
data, err := jt.SignedString(j.privKey)
return data, err
}

func (j *JWTHandlerRS256) FromJWT(tokstr string) (*Token, error) {
var err error
var jwttoken *jwtgo.Token
for _, privKey := range []*rsa.PrivateKey{
j.privKey,
j.fallbackPrivKey,
} {
if privKey != nil {
jwttoken, err = jwtgo.ParseWithClaims(tokstr, &Claims{},
func(token *jwtgo.Token) (interface{}, error) {
if _, ok := token.Method.(*jwtgo.SigningMethodRSA); !ok {
return nil, errors.New("unexpected signing method: " + token.Method.Alg())
}
return &privKey.PublicKey, nil
},
)
if jwttoken != nil && err == nil {
break
}
switch block.Type {
case pemHeaderPKCS1:
privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, errors.Wrap(err, "failed to read rsa private key")
}
}

// our Claims return Mender-specific validation errors
// go-jwt will wrap them in a generic ValidationError - unwrap and return directly
if err != nil {
err, ok := err.(*jwtgo.ValidationError)
if ok && err.Inner != nil {
return nil, err.Inner
return NewJWTHandlerRS256(privKey), nil
case pemHeaderPKCS8:
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.Wrap(err, "failed to read private key")
}
switch v := key.(type) {
case *rsa.PrivateKey:
return NewJWTHandlerRS256(v), nil
case ed25519.PrivateKey:
return NewJWTHandlerEd25519(&v), nil
}
return nil, err
}

token := Token{}

if claims, ok := jwttoken.Claims.(*Claims); ok && jwttoken.Valid {
token.Claims = *claims
return &token, nil
}
return nil, ErrTokenInvalid
return nil, errors.Errorf("unsupported server private key type")
}
62 changes: 62 additions & 0 deletions jwt/jwt_ed25519.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2023 Northern.tech AS
//
// 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.
package jwt

import (
"crypto/ed25519"

"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
)

// JWTHandlerEd25519 is an Ed25519-specific JWTHandler
type JWTHandlerEd25519 struct {
privKey *ed25519.PrivateKey
}

func NewJWTHandlerEd25519(privKey *ed25519.PrivateKey) *JWTHandlerEd25519 {
return &JWTHandlerEd25519{
privKey: privKey,
}
}

func (j *JWTHandlerEd25519) ToJWT(token *Token) (string, error) {
//generate
jt := jwt.NewWithClaims(jwt.SigningMethodEdDSA, &token.Claims)

//sign
data, err := jt.SignedString(j.privKey)
return data, err
}

func (j *JWTHandlerEd25519) FromJWT(tokstr string) (*Token, error) {
jwttoken, err := jwt.ParseWithClaims(tokstr, &Claims{},
func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, errors.New("unexpected signing method: " + token.Method.Alg())
}
return j.privKey.Public(), nil
},
)

if err == nil {
token := Token{}
if claims, ok := jwttoken.Claims.(*Claims); ok && jwttoken.Valid {
token.Claims = *claims
return &token, nil
}
}

return nil, ErrTokenInvalid
}
Loading

0 comments on commit 093aabe

Please sign in to comment.