Skip to content

Commit

Permalink
Merge pull request #426 from cisco-open/feat/jwt-improve-file-store
Browse files Browse the repository at this point in the history
Improve jwk file store to support different
  • Loading branch information
TimShi authored Jul 8, 2024
2 parents b382be7 + 6339407 commit 73db489
Show file tree
Hide file tree
Showing 52 changed files with 1,104 additions and 593 deletions.
2 changes: 2 additions & 0 deletions pkg/security/oauth2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# OAuth

63 changes: 63 additions & 0 deletions pkg/security/oauth2/jwt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# JWT

The JWT package provides support for working with JSON Web Tokens. This includes encoding and decoding tokens, as well as signing
and verifying the signature of tokens.

## JWK Store
In order to sign token (during encoding) and verify token (during decoding), the JWT package requires a JWK store. The JWK
store is responsible for providing the private and public keys used in the signing and verification process. These implementations
of the JWK stores are provided:

1. ```FileJwkStore```
2. ```RemoteJwkStore```

For testing, we also provide

1. ```SingleJwkStore``` - A JWK store that contains a randomly generated private key for the specified algorithm.
2. ```StaticJwkStore``` - A JWK store that contains a static list of randomly generated private key for the specified algorithm.

### FileJwkStore
This store is used to load JWKs from pem files. The file location is specified using configuration properties. This is the
default store used in the authorization server configuration.

```yaml
security:
keys:
my-key-name:
id: my-key-id
format: pem
file: my-key-file.pem
```
The FileJwkStore will load all the keys under the `security.keys` property. The pem file under each key name can contain
one or more keys. Key name is a way to categorize the key by usage. For example, you may want to have a different set of keys
for signing and encryption.

If the pem file contains one key. The key id of the key will equal to the name of the key. In this example, if `my-key-file.pem`
contains only one key. That key's key id will be `my-key-name`.

If the pem file contains multiple key. The key id of the key will either be based on the `id` property if it's provided. Or it will
be generated based on elements of the public key. In this example, since `id` is provided, the key id will be `my-key-id-1` and `my-key-id-2` etc.

#### Key Rotation
The FileJwkStore supports key rotation. If the pem file contains multiple keys, the `LoadByName` will return the current key for that name.
After `Rotate` is called, the current key will be moved to the next key in the pem file.

#### Supported Key Types
The FileJwkStore supports the following key types:

- RSA: PKCS8 unencrypted format. Tradition encrypted and unencrypted format.
- ECDSA: PKCS8 unencrypted format. Tradition encrypted and unencrypted format.
- ED25519: PKCS8 unencrypted format.
- HMAC: Custom unencrypted format.

See the [testdata](testdata/README.md) directory for examples of pem files and how to generate them using `openssl`.

#### Use HMAC Key with Caution
HMAC key is a symmetric key. This file store supports HMAC key. However, it should be used with caution. By default, the HMAC key
is included in the jwks endpoint which is by default public. If you want to use HMAC key, you should secure the jwks endpoint in
your application, or encrypt the jwks content by providing your own jwks implementation instead of the default one.

### RemoteJwkStore
This store is used to load JWKs from a remote endpoint. It's usually used when your application needs to verify the jwt
signature issued from an authorization server that publishes its public keys through its jwks endpoint.
11 changes: 10 additions & 1 deletion pkg/security/oauth2/jwt/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,16 @@ func (enc *SignedJwtEncoder) Encode(ctx context.Context, claims interface{}) (st
token = jwt.NewWithClaims(method, &jwtGoCompatibleClaims{claims: claims})
}

// set Kid if not default
// jwk.Name() could be an alias for more than one kid to support rotation.
//
// We expect the store implementation to return jwk whose ID is the same as its name if the store only has
// one key for that name (i.e. no-rotation), and intend to pass the decoder the key out of band.
// In this case, we don't need to set kid in the header, because we expect the decoder side can get this key
// because there is no ambiguity.
//
// We expect the store to return jwk whose ID is not the same as its name if the store has multiple key for that name
// (i.e. rotation).
// In this case, we need to set kid in the header.
if jwk.Id() != enc.jwkName {
token.Header[JwtHeaderKid] = jwk.Id()
}
Expand Down
7 changes: 6 additions & 1 deletion pkg/security/oauth2/jwt/jwk.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ type JwkStore interface {
// LoadByName returns the JWK associated with given name.
// The method might return different JWK for same name, if the store is also support rotation
// This method is usually used when encoding/encrypt JWT token
// Note: if the store does not support rotation (i.e. it doest not implement JwkRotator),
// this store could use the name as the jwk id. Doing so would allow the encoder to not
// add a "kid" header to the JWT token. This allows the use case where the JWT key is agreed upon by
// both the encoder and decoder through an out-of-band mechanism without using "kid".
// See the comment in SignedJwtEncoder.Encode for more details
LoadByName(ctx context.Context, name string) (Jwk, error)

// LoadAll return all JWK with given names. If name is not provided, all JWK is returned
Expand All @@ -62,7 +67,7 @@ type JwkRotator interface {
Implements Base
*********************/

// GenericJwk implements Jwk and PrivateJwk
// GenericJwk implements Jwk
type GenericJwk struct {
kid string
name string
Expand Down
136 changes: 109 additions & 27 deletions pkg/security/oauth2/jwt/jwk_store_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@
package jwt

import (
"context"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/binary"
"encoding/hex"
"fmt"
"github.com/cisco-open/go-lanai/pkg/utils/cryptoutils"
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/hmac"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"fmt"
"github.com/cisco-open/go-lanai/pkg/utils/cryptoutils"
"hash"
)

const (
Expand All @@ -39,6 +45,35 @@ const (
// FileJwkStore implements JwkStore and JwkRotator
// This store uses load key files for public and private keys.
// File locations and "kids" are read from properties. And rotate between pre-defined keys
// The properties are structured as follows:
//
// keys:
// my-key-name:
// id: my-key-id
// format: pem
// file: my-key-file.pem
//
// Keys loaded under the same key name will all have the same name. The LoadByName method will load one of the keys.
// Which key will be loaded is determined by the current index for that name. The Rotate method will increment the index
// for that name.
// If the pem file contains one key, the key id will be the same as the key name.
//
// If the pem file contains multiple keys, the following rules will be used to generate the key id:
//
// If id property is provided, the actual key id will be the property id plus an integer suffix.
// If id property is not provided, the actual key id will be generated based on elements of the public key. The ID value
// will be consistent across restarts.
//
// Supports PEM format.
// Supports:
// 1. PKCS8 unencrypted private key (rsa, ecdsa, ed25519)
// 2. traditional unencrypted private key and encrypted private key (rsa and ecdsa)
// 3. traditional public key (pkcs1 for rsa or pkix for rsa, PKIX for ecdsa and ed25519)
// 4. x509 certificate (rsa, ecdsa, ed25519)
// 5. HMAC key (using custom label "HMAC KEY", i.e. -----BEGIN HMAC KEY-----)
//
// Note that if HMAC is used, the application must be responsible for securing the jwks endpoint, or encrypt the jwks content.
// This is because HMAC keys are symmetric and should not be exposed to public. By default, the jwks endpoint is not secured.
type FileJwkStore struct {
cacheById map[string]Jwk
cacheByName map[string][]Jwk
Expand Down Expand Up @@ -146,23 +181,29 @@ func loadJwksFromPem(name string, props CryptoKeyProperties) ([]Jwk, error) {
privJwks := make([]Jwk, 0)
pubJwks := make([]Jwk, 0)
for i, v := range items {
var privKey *rsa.PrivateKey
var pubKey *rsa.PublicKey
var privKey crypto.PrivateKey
var pubKey crypto.PublicKey

// get private or public key
switch v.(type) {
case *rsa.PrivateKey:
privKey = v.(*rsa.PrivateKey)
case *rsa.PublicKey:
pubKey = v.(*rsa.PublicKey)
case *x509.Certificate:
var ok bool
if privKey, ok = v.(privateKey); ok {
// got private key, do nothing
} else if pubKey, ok = v.(publicKey); ok {
// got public key, do nothing
} else if _, ok = v.(*x509.Certificate); ok {
cert := v.(*x509.Certificate)
k, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
if pubKey, ok = cert.PublicKey.(publicKey); !ok {
return nil, fmt.Errorf(errTmplUnsupportedPubKey, cert.PublicKey)
}
pubKey = k
default:
} else if _, ok = v.(*pem.Block); ok {
switch v.(*pem.Block).Type {
case "HMAC KEY":
logger.Warnf("File contains HMAC keys, please make sure the jwks end point is secured")
privKey = v.(*pem.Block).Bytes
default:
return nil, fmt.Errorf(errTmplUnsupportedBlock, v)
}
} else {
return nil, fmt.Errorf(errTmplUnsupportedBlock, v)
}

Expand All @@ -171,12 +212,12 @@ func loadJwksFromPem(name string, props CryptoKeyProperties) ([]Jwk, error) {
case privKey == nil && len(privJwks) != 0:
return nil, fmt.Errorf(errTmplPubPrivMixed)
case privKey == nil:
kid := calculateKid(props, name, i, pubKey)
kid := calculateKid(props, name, i, len(items), pubKey)
pubJwks = append(pubJwks, NewJwk(kid, name, pubKey))
case len(pubJwks) != 0:
return nil, fmt.Errorf(errTmplPubPrivMixed)
default:
kid := calculateKid(props, name, i, &privKey.PublicKey)
kid := calculateKid(props, name, i, len(items), privKey)
privJwks = append(privJwks, NewPrivateJwk(kid, name, privKey))
}
}
Expand All @@ -196,17 +237,58 @@ func loadJwksFromPem(name string, props CryptoKeyProperties) ([]Jwk, error) {
}
}

func calculateKid(props CryptoKeyProperties, name string, blockIndex int, key *rsa.PublicKey) string {
func calculateKid(props CryptoKeyProperties, name string, blockIndex int, numBlocks int, key any) string {
if numBlocks == 1 {
return name
}

if props.Id != "" {
return fmt.Sprintf("%s-%d", props.Id, blockIndex)
}

// best effort to create a unique suffix for the kid
//best effort to generate a kid that is consistent across restarts
var hash hash.Hash
switch key.(type) {
case *rsa.PrivateKey:
privKey := key.(*rsa.PrivateKey)
hash = hashForRSA(&privKey.PublicKey)
case *rsa.PublicKey:
hash = hashForRSA(key.(*rsa.PublicKey))
case *ecdsa.PrivateKey:
privKey := key.(*ecdsa.PrivateKey)
hash = hashForEcdsa(privKey.Public().(*ecdsa.PublicKey))
case *ecdsa.PublicKey:
hash = hashForEcdsa(key.(*ecdsa.PublicKey))
case ed25519.PrivateKey:
privKey := key.(ed25519.PrivateKey)
hash = hashForEd25519(privKey.Public().(ed25519.PublicKey))
case ed25519.PublicKey:
hash = hashForEd25519(key.(ed25519.PublicKey))
case []byte:
hash = hmac.New(sha256.New, key.([]byte))
hash.Write([]byte(name))
}
sum := hash.Sum(nil)
suffix := hex.EncodeToString(sum)
return name + "-" + suffix
}

func hashForRSA(key *rsa.PublicKey) hash.Hash {
hash := sha256.New224()
_, _ = hash.Write(key.N.Bytes())
_ = binary.Write(hash, binary.LittleEndian, int64(key.E))
sum := hash.Sum(nil)
suffix := hex.EncodeToString(sum)
return hash
}

return name + "-" + suffix
func hashForEd25519(key ed25519.PublicKey) hash.Hash {
hash := sha256.New224()
_, _ = hash.Write(key)
return hash
}

func hashForEcdsa(key *ecdsa.PublicKey) hash.Hash {
hash := sha256.New224()
_, _ = hash.Write(key.X.Bytes())
_, _ = hash.Write(key.Y.Bytes())
return hash
}
Loading

0 comments on commit 73db489

Please sign in to comment.