Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add the token's rootcert public key to the list of known keys #4471

Merged
merged 1 commit into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/content/about/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
29 changes: 20 additions & 9 deletions docs/content/spec/auth/jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/content/spec/auth/token.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions registry/auth/token/accesscontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand Down
92 changes: 91 additions & 1 deletion registry/auth/token/accesscontroller_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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.
josegomezr marked this conversation as resolved.
Show resolved Hide resolved
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.
josegomezr marked this conversation as resolved.
Show resolved Hide resolved
ac2, _ := ac.(*accessController)
josegomezr marked this conversation as resolved.
Show resolved Hide resolved
if got := len(ac2.trustedKeys); got != 1 {
t.Fatalf("Unexpected number of trusted keys, expected 1 got: %d", got)
}
}
48 changes: 48 additions & 0 deletions registry/auth/token/util.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
Loading