mirror of
https://github.com/distribution/distribution.git
synced 2025-09-19 01:17:32 +00:00
fix: Add the token's rootcert public key to the list of known keys
- Add Unit tests for `token.newAccessController` + Implemented swappable implementations for `token.getRootCerts` and `getJwks` to unit test their behavior over the accessController struct. - Use RFC7638 [0] mechanics to compute the KeyID of the rootcertbundle provided in the token auth config. - Extends token authentication docs: + Extend `jwt.md` write up on JWT headers & JWT Validation + Updated old reference to a draft that's now RFC7515. + Extended the JWT validation steps with the JWT Header validation. + Reference `jwt.md` in `token.md` [0]: https://datatracker.ietf.org/doc/html/rfc7638#autoid-13 Signed-off-by: Jose D. Gomez R <jose.gomez@suse.com>
This commit is contained in:
committed by
Jose D. Gomez R
parent
b74618692d
commit
b53946ded3
@@ -238,6 +238,11 @@ func checkOptions(options map[string]interface{}) (tokenAccessOptions, error) {
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
var (
|
||||
rootCertFetcher func(string) ([]*x509.Certificate, error) = getRootCerts
|
||||
jwkFetcher func(string) (*jose.JSONWebKeySet, error) = getJwks
|
||||
)
|
||||
|
||||
func getRootCerts(path string) ([]*x509.Certificate, error) {
|
||||
fp, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -316,14 +321,14 @@ func newAccessController(options map[string]interface{}) (auth.AccessController,
|
||||
)
|
||||
|
||||
if config.rootCertBundle != "" {
|
||||
rootCerts, err = getRootCerts(config.rootCertBundle)
|
||||
rootCerts, err = rootCertFetcher(config.rootCertBundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if config.jwks != "" {
|
||||
jwks, err = getJwks(config.jwks)
|
||||
jwks, err = jwkFetcher(config.jwks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -334,12 +339,15 @@ func newAccessController(options map[string]interface{}) (auth.AccessController,
|
||||
return nil, errors.New("token auth requires at least one token signing key")
|
||||
}
|
||||
|
||||
trustedKeys := make(map[string]crypto.PublicKey)
|
||||
rootPool := x509.NewCertPool()
|
||||
for _, rootCert := range rootCerts {
|
||||
rootPool.AddCert(rootCert)
|
||||
if key := GetRFC7638Thumbprint(rootCert.PublicKey); key != "" {
|
||||
trustedKeys[key] = rootCert.PublicKey
|
||||
}
|
||||
}
|
||||
|
||||
trustedKeys := make(map[string]crypto.PublicKey)
|
||||
if jwks != nil {
|
||||
for _, key := range jwks.Keys {
|
||||
trustedKeys[key.KeyID] = key.Public()
|
||||
|
@@ -1,9 +1,16 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
func TestBuildAutoRedirectURL(t *testing.T) {
|
||||
@@ -87,3 +94,86 @@ func TestCheckOptions(t *testing.T) {
|
||||
t.Fatal("autoredirectpath should be /auth/token")
|
||||
}
|
||||
}
|
||||
|
||||
func mockGetRootCerts(path string) ([]*x509.Certificate, error) {
|
||||
caPrivKey, err := rsa.GenerateKey(rand.Reader, 1024) // not to slow down the test that much
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ca := &x509.Certificate{
|
||||
PublicKey: &caPrivKey.PublicKey,
|
||||
}
|
||||
|
||||
return []*x509.Certificate{ca}, nil
|
||||
}
|
||||
|
||||
func mockGetJwks(path string) (*jose.JSONWebKeySet, error) {
|
||||
return &jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
KeyID: "sample-key-id",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRootCertIncludedInTrustedKeys(t *testing.T) {
|
||||
old := rootCertFetcher
|
||||
rootCertFetcher = mockGetRootCerts
|
||||
defer func() { rootCertFetcher = old }()
|
||||
|
||||
realm := "https://auth.example.com/token/"
|
||||
issuer := "test-issuer.example.com"
|
||||
service := "test-service.example.com"
|
||||
|
||||
options := map[string]interface{}{
|
||||
"realm": realm,
|
||||
"issuer": issuer,
|
||||
"service": service,
|
||||
"rootcertbundle": "something-to-trigger-our-mock",
|
||||
"autoredirect": true,
|
||||
"autoredirectpath": "/auth",
|
||||
}
|
||||
|
||||
ac, err := newAccessController(options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// newAccessController return type is an interface built from
|
||||
// accessController struct. The type check can be safely ignored.
|
||||
ac2, _ := ac.(*accessController)
|
||||
if got := len(ac2.trustedKeys); got != 1 {
|
||||
t.Fatalf("Unexpected number of trusted keys, expected 1 got: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSIncludedInTrustedKeys(t *testing.T) {
|
||||
old := jwkFetcher
|
||||
jwkFetcher = mockGetJwks
|
||||
defer func() { jwkFetcher = old }()
|
||||
|
||||
realm := "https://auth.example.com/token/"
|
||||
issuer := "test-issuer.example.com"
|
||||
service := "test-service.example.com"
|
||||
|
||||
options := map[string]interface{}{
|
||||
"realm": realm,
|
||||
"issuer": issuer,
|
||||
"service": service,
|
||||
"jwks": "something-to-trigger-our-mock",
|
||||
"autoredirect": true,
|
||||
"autoredirectpath": "/auth",
|
||||
}
|
||||
|
||||
ac, err := newAccessController(options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// newAccessController return type is an interface built from
|
||||
// accessController struct. The type check can be safely ignored.
|
||||
ac2, _ := ac.(*accessController)
|
||||
if got := len(ac2.trustedKeys); got != 1 {
|
||||
t.Fatalf("Unexpected number of trusted keys, expected 1 got: %d", got)
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,15 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// actionSet is a special type of stringSet.
|
||||
type actionSet struct {
|
||||
stringSet
|
||||
@@ -36,3 +46,41 @@ func containsAny(ss []string, q []string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NOTE: RFC7638 does not prescribe which hashing function to use, but suggests
|
||||
// sha256 as a sane default as of time of writing
|
||||
func hashAndEncode(payload string) string {
|
||||
shasum := sha256.Sum256([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(shasum[:])
|
||||
}
|
||||
|
||||
// RFC7638 states in section 3 sub 1 that the keys in the JSON object payload
|
||||
// are required to be ordered lexicographical order. Golang does not guarantee
|
||||
// order of keys[0]
|
||||
// [0]: https://groups.google.com/g/golang-dev/c/zBQwhm3VfvU
|
||||
//
|
||||
// The payloads are small enough to create the JSON strings manually
|
||||
func GetRFC7638Thumbprint(publickey crypto.PublicKey) string {
|
||||
var payload string
|
||||
|
||||
switch pubkey := publickey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
e_big := big.NewInt(int64(pubkey.E)).Bytes()
|
||||
|
||||
e := base64.RawURLEncoding.EncodeToString(e_big)
|
||||
n := base64.RawURLEncoding.EncodeToString(pubkey.N.Bytes())
|
||||
|
||||
payload = fmt.Sprintf(`{"e":"%s","kty":"RSA","n":"%s"}`, e, n)
|
||||
case *ecdsa.PublicKey:
|
||||
params := pubkey.Params()
|
||||
crv := params.Name
|
||||
x := base64.RawURLEncoding.EncodeToString(params.Gx.Bytes())
|
||||
y := base64.RawURLEncoding.EncodeToString(params.Gy.Bytes())
|
||||
|
||||
payload = fmt.Sprintf(`{"crv":"%s","kty":"EC","x":"%s","y":"%s"}`, crv, x, y)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
return hashAndEncode(payload)
|
||||
}
|
||||
|
Reference in New Issue
Block a user