Auth: Add SigningKeys Service (#64343)

* Add key service

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>

* Wire the service

* Rename Service

* Implement GetJWKS

* Slipt interface and implementation

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>

* Change implementation, add tests

* Align to the expected package hierarchy

* Update CODEOWNERS

* Align names and fix wire.go

* Update pkg/services/signingkeys/signingkeysimpl/service.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* Update pkg/services/signingkeys/signingkeysimpl/service_test.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* Update pkg/services/signingkeys/signingkeysimpl/service_test.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* Update pkg/services/signingkeys/signingkeysimpl/service_test.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* Add AddPrivateKey method to SigningKeysService

* Align tests to the guidelines

* Add test for GetJWKS() method

* Add comments to the interface

* Add FakeSigningKeysService

---------

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>
Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
linoman 2023-04-17 11:42:37 +02:00 committed by GitHub
parent 3c2a69c82c
commit 4027254b87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 490 additions and 0 deletions

1
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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(

View File

@ -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")
)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}