From b53946ded3d24553e448262c247f0a63af944410 Mon Sep 17 00:00:00 2001 From: "Jose D. Gomez R" Date: Tue, 24 Sep 2024 16:13:59 +0200 Subject: [PATCH] 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 --- docs/content/about/configuration.md | 5 ++ docs/content/spec/auth/jwt.md | 29 ++++-- docs/content/spec/auth/token.md | 3 +- registry/auth/token/accesscontroller.go | 14 ++- registry/auth/token/accesscontroller_test.go | 92 +++++++++++++++++++- registry/auth/token/util.go | 48 ++++++++++ 6 files changed, 177 insertions(+), 14 deletions(-) diff --git a/docs/content/about/configuration.md b/docs/content/about/configuration.md index 88c4e8589..0411fc79e 100644 --- a/docs/content/about/configuration.md +++ b/docs/content/about/configuration.md @@ -666,6 +666,11 @@ Default `signingalgorithms`: - PS384 - PS512 +Additional notes on `rootcertbundle`: + +- The public key of this certificate will be automatically added to the list of known keys. +- The public key will be identified by it's [RFC7638 Thumbprint](https://datatracker.ietf.org/doc/html/rfc7638). + For more information about Token based authentication configuration, see the [specification](../spec/auth/token.md). diff --git a/docs/content/spec/auth/jwt.md b/docs/content/spec/auth/jwt.md index 80a790f3a..ef1a85985 100644 --- a/docs/content/spec/auth/jwt.md +++ b/docs/content/spec/auth/jwt.md @@ -12,7 +12,7 @@ Web Token schema that `distribution/distribution` has adopted to implement the client-opaque Bearer token issued by an authentication service and understood by the registry. -This document borrows heavily from the [JSON Web Token Draft Spec](https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32) +This document borrows heavily from the [JSON Web Token Spec: RFC7519](https://datatracker.ietf.org/doc/html/rfc7519) ## Getting a Bearer Token @@ -63,14 +63,19 @@ Token has 3 main parts: 1. Headers - The header of a JSON Web Token is a standard JOSE header. The "typ" field - will be "JWT" and it will also contain the "alg" which identifies the - signing algorithm used to produce the signature. It also must have a "kid" - field, representing the ID of the key which was used to sign the token. + The header of a JSON Web Token is a standard JOSE header compliant with + [Section 5 of RFC7519](https://datatracker.ietf.org/doc/html/rfc7515#section-5). - It specifies that this object is going to be a JSON Web token signed using - the key with the given ID using the Elliptic Curve signature algorithm - using a SHA256 hash. + It **must** have: + + * `alg` **(Algorithm)**: Identifies the signing algorithm used to produce the signature. + * `typ` **(Type)**: Must be equal to `JWT` as recommended by [Section 5.1 RFC7519](https://datatracker.ietf.org/doc/html/rfc7519#section-5.1) + + It should have at least one of: + + * `kid` **(KeyID)**: Represents the ID of the key which was used to sign the token. + * `jwk` **(JWK)**: Represents the public key used to sign the token, compliant with [RFC7517](https://datatracker.ietf.org/doc/html/rfc7517) + * `x5c` **(X.509 Certificate Chain)**: Represents the chain of certificates used to sign the token. 2. Claim Set @@ -226,7 +231,7 @@ Token has 3 main parts: This is then used as the payload to a the `ES256` signature algorithm specified in the JOSE header and specified fully in [Section 3.4 of the JSON Web Algorithms (JWA) - draft specification](https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-38#section-3.4) + specification](https://datatracker.ietf.org/doc/html/rfc7518) This example signature will use the following ECDSA key for the server: @@ -281,6 +286,12 @@ This is also described in [Section 2.1 of RFC 6750: The OAuth 2.0 Authorization The registry must now verify the token presented by the user by inspecting the claim set within. The registry will: +- Ensure that the certificate chain provided (in the `x5c` header) is valid. + - If it fails (eg. `x5c` header not present), then the registry will either: + - If provided, verify the provided JWK (in the `jwt` header) in the JWT is + known or trusted. + - If provided, verify that the provided KeyID (in the `kid` header) is a + known (as per configured in `auth.token.jwks` config). - Ensure that the issuer (`iss` claim) is an authority it trusts. - Ensure that the registry identifies as the audience (`aud` claim). - Check that the current time is between the `nbf` and `exp` claim times. diff --git a/docs/content/spec/auth/token.md b/docs/content/spec/auth/token.md index a878b3281..f3b117347 100644 --- a/docs/content/spec/auth/token.md +++ b/docs/content/spec/auth/token.md @@ -16,7 +16,8 @@ This document outlines the v2 Distribution registry authentication scheme: 3. The registry client makes a request to the authorization service for a Bearer token. 4. The authorization service returns an opaque Bearer token representing the - client's authorized access. + client's authorized access. The token must comply with the structure + described in the [Token Authentication Implementation page](./jwt.md). 5. The client retries the original request with the Bearer token embedded in the request's Authorization header. 6. The Registry authorizes the client by validating the Bearer token and the diff --git a/registry/auth/token/accesscontroller.go b/registry/auth/token/accesscontroller.go index 3d6b9c2c4..e86989538 100644 --- a/registry/auth/token/accesscontroller.go +++ b/registry/auth/token/accesscontroller.go @@ -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() diff --git a/registry/auth/token/accesscontroller_test.go b/registry/auth/token/accesscontroller_test.go index fde203114..ba3d0a37a 100644 --- a/registry/auth/token/accesscontroller_test.go +++ b/registry/auth/token/accesscontroller_test.go @@ -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) + } +} diff --git a/registry/auth/token/util.go b/registry/auth/token/util.go index 52e9bae21..29b8811fd 100644 --- a/registry/auth/token/util.go +++ b/registry/auth/token/util.go @@ -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) +}