diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e5b39730559..07c06825e28 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -522,6 +522,7 @@ lerna.json @grafana/frontend-ops /pkg/services/anonymous/ @grafana/grafana-authnz-team /pkg/services/auth/ @grafana/grafana-authnz-team /pkg/services/authn/ @grafana/grafana-authnz-team +/pkg/services/signingkeys/ @grafana/grafana-authnz-team /pkg/services/dashboards/accesscontrol.go @grafana/grafana-authnz-team /pkg/services/datasources/permissions/ @grafana/grafana-authnz-team /pkg/services/guardian/ @grafana/grafana-authnz-team diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 5a744fe569f..8882bb443e6 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -115,6 +115,8 @@ import ( serviceaccountsretriever "github.com/grafana/grafana/pkg/services/serviceaccounts/retriever" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/shorturls/shorturlimpl" + "github.com/grafana/grafana/pkg/services/signingkeys" + "github.com/grafana/grafana/pkg/services/signingkeys/signingkeysimpl" "github.com/grafana/grafana/pkg/services/sqlstore" starApi "github.com/grafana/grafana/pkg/services/star/api" "github.com/grafana/grafana/pkg/services/star/starimpl" @@ -359,6 +361,8 @@ var wireBasicSet = wire.NewSet( supportbundlesimpl.ProvideService, loggermw.Provide, modules.WireSet, + signingkeysimpl.ProvideEmbeddedSigningKeysService, + wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ) var wireSet = wire.NewSet( diff --git a/pkg/services/signingkeys/error.go b/pkg/services/signingkeys/error.go new file mode 100644 index 00000000000..2ef529f00fe --- /dev/null +++ b/pkg/services/signingkeys/error.go @@ -0,0 +1,9 @@ +package signingkeys + +import "github.com/grafana/grafana/pkg/util/errutil" + +var ( + ErrSigningKeyNotFound = errutil.NewBase(errutil.StatusNotFound, "signingkeys.keyNotFound") + ErrSigningKeyAlreadyExists = errutil.NewBase(errutil.StatusBadRequest, "signingkeys.keyAlreadyExists") + ErrKeyGenerationFailed = errutil.NewBase(errutil.StatusInternal, "signingkeys.keyGenerationFailed") +) diff --git a/pkg/services/signingkeys/signingkeys.go b/pkg/services/signingkeys/signingkeys.go new file mode 100644 index 00000000000..c3e37e44339 --- /dev/null +++ b/pkg/services/signingkeys/signingkeys.go @@ -0,0 +1,32 @@ +// Package signingkeys implements the SigningKeys service which is responsible for managing +// the signing keys used to sign and verify JWT tokens. +// +// The service is under active development and is not yet ready for production use. +// +// Currently, it only supports RSA keys and the keys are stored in memory. + +package signingkeys + +import ( + "crypto" + + "github.com/go-jose/go-jose/v3" +) + +// Service provides functionality for managing signing keys used to sign and verify JWT tokens. +// +// The service is under active development and is not yet ready for production use. +type Service interface { + // GetJWKS returns the JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys) + GetJWKS() jose.JSONWebKeySet + // GetJWK returns the JSON Web Key (JWK) with the specified key ID which can be used to verify tokens (public key) + GetJWK(keyID string) (jose.JSONWebKey, error) + // GetPublicKey returns the public key with the specified key ID + GetPublicKey(keyID string) (crypto.PublicKey, error) + // GetPrivateKey returns the private key with the specified key ID + GetPrivateKey(keyID string) (crypto.PrivateKey, error) + // GetServerPrivateKey returns the private key used to sign tokens + GetServerPrivateKey() (crypto.PrivateKey, error) + // AddPrivateKey adds a private key to the service + AddPrivateKey(keyID string, privateKey crypto.PrivateKey) error +} diff --git a/pkg/services/signingkeys/signingkeysimpl/service.go b/pkg/services/signingkeys/signingkeysimpl/service.go new file mode 100644 index 00000000000..30d0ec99612 --- /dev/null +++ b/pkg/services/signingkeys/signingkeysimpl/service.go @@ -0,0 +1,113 @@ +package signingkeysimpl + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + + "github.com/go-jose/go-jose/v3" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/signingkeys" +) + +const ( + serverPrivateKeyID = "default" +) + +var _ signingkeys.Service = new(Service) + +func ProvideEmbeddedSigningKeysService(features *featuremgmt.FeatureManager) (*Service, error) { + s := &Service{ + log: log.New("auth.key_service"), + keys: map[string]crypto.Signer{}, + } + + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + s.log.Error("Error generating private key", "err", err) + return nil, signingkeys.ErrKeyGenerationFailed.Errorf("Error generating private key: %v", err) + } + + if err := s.AddPrivateKey(serverPrivateKeyID, privateKey); err != nil { + return nil, err + } + + return s, nil +} + +// Service provides functionality for managing signing keys used to sign and verify JWT tokens for +// the OSS version of Grafana. +// +// The service is under active development and is not yet ready for production use. +type Service struct { + log log.Logger + keys map[string]crypto.Signer +} + +// GetJWKS returns the JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys) +func (s *Service) GetJWKS() jose.JSONWebKeySet { + result := jose.JSONWebKeySet{} + + for keyID := range s.keys { + // Skip error check because keyID must be a valid key ID + jwk, _ := s.GetJWK(keyID) + result.Keys = append(result.Keys, jwk) + } + + return result +} + +// GetJWK returns the JSON Web Key (JWK) with the specified key ID which can be used to verify tokens (public key) +func (s *Service) GetJWK(keyID string) (jose.JSONWebKey, error) { + privateKey, ok := s.keys[keyID] + if !ok { + s.log.Error("The specified key was not found", "keyID", keyID) + return jose.JSONWebKey{}, signingkeys.ErrSigningKeyNotFound.Errorf("The specified key was not found: %s", keyID) + } + + result := jose.JSONWebKey{ + Key: privateKey.Public(), + Use: "sig", + } + + return result, nil +} + +// GetPublicKey returns the public key with the specified key ID +func (s *Service) GetPublicKey(keyID string) (crypto.PublicKey, error) { + privateKey, ok := s.keys[keyID] + if !ok { + s.log.Error("The specified key was not found", "keyID", keyID) + return nil, signingkeys.ErrSigningKeyNotFound.Errorf("The specified key was not found: %s", keyID) + } + + return privateKey.Public(), nil +} + +// GetPrivateKey returns the private key with the specified key ID +func (s *Service) GetPrivateKey(keyID string) (crypto.PrivateKey, error) { + privateKey, ok := s.keys[keyID] + if !ok { + s.log.Error("The specified key was not found", "keyID", keyID) + return nil, signingkeys.ErrSigningKeyNotFound.Errorf("The specified key was not found: %s", keyID) + } + + return privateKey, nil +} + +// AddPrivateKey adds a private key to the service +func (s *Service) AddPrivateKey(keyID string, privateKey crypto.PrivateKey) error { + if _, ok := s.keys[keyID]; ok { + s.log.Error("The specified key ID is already in use", "keyID", keyID) + return signingkeys.ErrSigningKeyAlreadyExists.Errorf("The specified key ID is already in use: %s", keyID) + } + s.keys[keyID] = privateKey.(crypto.Signer) + return nil +} + +// GetServerPrivateKey returns the private key used to sign tokens +func (s *Service) GetServerPrivateKey() (crypto.PrivateKey, error) { + return s.GetPrivateKey(serverPrivateKeyID) +} diff --git a/pkg/services/signingkeys/signingkeysimpl/service_test.go b/pkg/services/signingkeys/signingkeysimpl/service_test.go new file mode 100644 index 00000000000..f4c891820b9 --- /dev/null +++ b/pkg/services/signingkeys/signingkeysimpl/service_test.go @@ -0,0 +1,283 @@ +package signingkeysimpl + +import ( + "crypto" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "testing" + + "github.com/go-jose/go-jose/v3" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +const ( + privateKeyPem = `-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY-----` +) + +func getPrivateKey(t *testing.T) *rsa.PrivateKey { + pemBlock, _ := pem.Decode([]byte(privateKeyPem)) + privateKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes) + require.NoError(t, err) + return privateKey +} + +func setupTestService(t *testing.T) *Service { + svc := &Service{ + log: log.NewNopLogger(), + keys: map[string]crypto.Signer{serverPrivateKeyID: getPrivateKey(t)}, + } + return svc +} + +func TestEmbeddedKeyService_GetJWK(t *testing.T) { + tests := []struct { + name string + keyID string + want jose.JSONWebKey + wantErr bool + }{ + {name: "creates a JSON Web Key successfully", + keyID: "default", + want: jose.JSONWebKey{ + Key: getPrivateKey(t).Public(), + Use: "sig", + }, + wantErr: false, + }, + {name: "returns error when the specified key was not found", + keyID: "not-existing-key-id", + want: jose.JSONWebKey{}, + wantErr: true, + }, + } + svc := setupTestService(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.GetJWK(tt.keyID) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, got, tt.want) + }) + } +} + +func TestEmbeddedKeyService_GetJWK_OnlyPublicKeyShared(t *testing.T) { + svc := setupTestService(t) + jwk, err := svc.GetJWK("default") + + require.NoError(t, err) + + jwkJson, err := jwk.MarshalJSON() + require.NoError(t, err) + + kvs := make(map[string]interface{}) + err = json.Unmarshal(jwkJson, &kvs) + require.NoError(t, err) + + // check that the private key is not shared + require.NotContains(t, kvs, "d") + require.NotContains(t, kvs, "p") + require.NotContains(t, kvs, "q") +} + +func TestEmbeddedKeyService_GetJWKS(t *testing.T) { + svc := &Service{ + log: log.NewNopLogger(), + keys: map[string]crypto.Signer{ + serverPrivateKeyID: getPrivateKey(t), + "other": getPrivateKey(t), + }, + } + jwk := svc.GetJWKS() + + require.Equal(t, 2, len(jwk.Keys)) +} + +func TestEmbeddedKeyService_GetJWKS_OnlyPublicKeyShared(t *testing.T) { + svc := setupTestService(t) + jwks := svc.GetJWKS() + + jwksJson, err := json.Marshal(jwks) + require.NoError(t, err) + + type keys struct { + Keys []map[string]interface{} `json:"keys"` + } + + var kvs keys + err = json.Unmarshal(jwksJson, &kvs) + require.NoError(t, err) + + for _, kv := range kvs.Keys { + // check that the private key is not shared + require.NotContains(t, kv, "d") + require.NotContains(t, kv, "p") + require.NotContains(t, kv, "q") + } +} + +func TestEmbeddedKeyService_GetPublicKey(t *testing.T) { + tests := []struct { + name string + keyID string + want crypto.PublicKey + wantErr bool + }{ + { + name: "returns the public key successfully", + keyID: "default", + want: getPrivateKey(t).Public(), + wantErr: false, + }, + { + name: "returns error when the specified key was not found", + keyID: "not-existent-key-id", + want: nil, + wantErr: true, + }, + } + svc := setupTestService(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.GetPublicKey(tt.keyID) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, got, tt.want) + }) + } +} + +func TestEmbeddedKeyService_GetPrivateKey(t *testing.T) { + tests := []struct { + name string + keyID string + want crypto.PrivateKey + wantErr bool + }{ + { + name: "returns the private key successfully", + keyID: "default", + want: getPrivateKey(t), + wantErr: false, + }, + { + name: "returns error when the specified key was not found", + keyID: "not-existent-key-id", + want: nil, + wantErr: true, + }, + } + svc := setupTestService(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.GetPrivateKey(tt.keyID) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, got, tt.want) + }) + } +} + +func TestEmbeddedKeyService_AddPrivateKey(t *testing.T) { + tests := []struct { + name string + keyID string + wantErr bool + }{ + { + name: "adds the private key successfully", + keyID: "new-key-id", + wantErr: false, + }, + { + name: "returns error when the specified key is already in the store", + keyID: serverPrivateKeyID, + wantErr: true, + }, + } + svc := setupTestService(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := svc.AddPrivateKey(tt.keyID, &dummyPrivateKey{}) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +type dummyPrivateKey struct { +} + +func (d dummyPrivateKey) Public() crypto.PublicKey { + return "" +} + +func (d dummyPrivateKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + return nil, nil +} diff --git a/pkg/services/signingkeys/signingkeystest/fake.go b/pkg/services/signingkeys/signingkeystest/fake.go new file mode 100644 index 00000000000..4e9163c2970 --- /dev/null +++ b/pkg/services/signingkeys/signingkeystest/fake.go @@ -0,0 +1,48 @@ +package signingkeystest + +import ( + "crypto" + + "github.com/go-jose/go-jose/v3" +) + +type FakeSigningKeysService struct { + ExpectedJSONWebKeySet jose.JSONWebKeySet + ExpectedJSONWebKey jose.JSONWebKey + ExpectedKeys map[string]crypto.Signer + ExpectedServerPrivateKey crypto.PrivateKey + ExpectedError error +} + +func (s *FakeSigningKeysService) GetJWKS() jose.JSONWebKeySet { + return s.ExpectedJSONWebKeySet +} + +// GetJWK returns the JSON Web Key (JWK) with the specified key ID which can be used to verify tokens (public key) +func (s *FakeSigningKeysService) GetJWK(keyID string) (jose.JSONWebKey, error) { + return s.ExpectedJSONWebKey, s.ExpectedError +} + +// GetPublicKey returns the public key with the specified key ID +func (s *FakeSigningKeysService) GetPublicKey(keyID string) (crypto.PublicKey, error) { + return s.ExpectedKeys[keyID].Public(), s.ExpectedError +} + +// GetPrivateKey returns the private key with the specified key ID +func (s *FakeSigningKeysService) GetPrivateKey(keyID string) (crypto.PrivateKey, error) { + return s.ExpectedKeys[keyID], s.ExpectedError +} + +// GetServerPrivateKey returns the private key used to sign tokens +func (s *FakeSigningKeysService) GetServerPrivateKey() (crypto.PrivateKey, error) { + return s.ExpectedServerPrivateKey, s.ExpectedError +} + +// AddPrivateKey adds a private key to the service +func (s *FakeSigningKeysService) AddPrivateKey(keyID string, privateKey crypto.PrivateKey) error { + if s.ExpectedError != nil { + return s.ExpectedError + } + s.ExpectedKeys[keyID] = privateKey.(crypto.Signer) + return nil +}