1
0
mirror of https://github.com/distribution/distribution synced 2024-11-12 05:45:51 +01: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:
Jose D. Gomez R 2024-09-24 16:13:59 +02:00 committed by Jose D. Gomez R
parent b74618692d
commit b53946ded3
No known key found for this signature in database
GPG Key ID: 8F018AD634A95BCD
6 changed files with 177 additions and 14 deletions

@ -666,6 +666,11 @@ Default `signingalgorithms`:
- PS384 - PS384
- PS512 - 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 For more information about Token based authentication configuration, see the
[specification](../spec/auth/token.md). [specification](../spec/auth/token.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 client-opaque Bearer token issued by an authentication service and
understood by the registry. 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 ## Getting a Bearer Token
@ -63,14 +63,19 @@ Token has 3 main parts:
1. Headers 1. Headers
The header of a JSON Web Token is a standard JOSE header. The "typ" field The header of a JSON Web Token is a standard JOSE header compliant with
will be "JWT" and it will also contain the "alg" which identifies the [Section 5 of RFC7519](https://datatracker.ietf.org/doc/html/rfc7515#section-5).
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.
It specifies that this object is going to be a JSON Web token signed using It **must** have:
the key with the given ID using the Elliptic Curve signature algorithm
using a SHA256 hash. * `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 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 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) 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: 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 The registry must now verify the token presented by the user by inspecting the
claim set within. The registry will: 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 issuer (`iss` claim) is an authority it trusts.
- Ensure that the registry identifies as the audience (`aud` claim). - Ensure that the registry identifies as the audience (`aud` claim).
- Check that the current time is between the `nbf` and `exp` claim times. - Check that the current time is between the `nbf` and `exp` claim times.

@ -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 3. The registry client makes a request to the authorization service for a
Bearer token. Bearer token.
4. The authorization service returns an opaque Bearer token representing the 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 5. The client retries the original request with the Bearer token embedded in
the request's Authorization header. the request's Authorization header.
6. The Registry authorizes the client by validating the Bearer token and the 6. The Registry authorizes the client by validating the Bearer token and the

@ -238,6 +238,11 @@ func checkOptions(options map[string]interface{}) (tokenAccessOptions, error) {
return opts, nil 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) { func getRootCerts(path string) ([]*x509.Certificate, error) {
fp, err := os.Open(path) fp, err := os.Open(path)
if err != nil { if err != nil {
@ -316,14 +321,14 @@ func newAccessController(options map[string]interface{}) (auth.AccessController,
) )
if config.rootCertBundle != "" { if config.rootCertBundle != "" {
rootCerts, err = getRootCerts(config.rootCertBundle) rootCerts, err = rootCertFetcher(config.rootCertBundle)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
if config.jwks != "" { if config.jwks != "" {
jwks, err = getJwks(config.jwks) jwks, err = jwkFetcher(config.jwks)
if err != nil { if err != nil {
return nil, err 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") return nil, errors.New("token auth requires at least one token signing key")
} }
trustedKeys := make(map[string]crypto.PublicKey)
rootPool := x509.NewCertPool() rootPool := x509.NewCertPool()
for _, rootCert := range rootCerts { for _, rootCert := range rootCerts {
rootPool.AddCert(rootCert) rootPool.AddCert(rootCert)
if key := GetRFC7638Thumbprint(rootCert.PublicKey); key != "" {
trustedKeys[key] = rootCert.PublicKey
}
} }
trustedKeys := make(map[string]crypto.PublicKey)
if jwks != nil { if jwks != nil {
for _, key := range jwks.Keys { for _, key := range jwks.Keys {
trustedKeys[key.KeyID] = key.Public() trustedKeys[key.KeyID] = key.Public()

@ -1,9 +1,16 @@
package token package token
import ( import (
"testing"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing"
"github.com/go-jose/go-jose/v4"
) )
func TestBuildAutoRedirectURL(t *testing.T) { func TestBuildAutoRedirectURL(t *testing.T) {
@ -87,3 +94,86 @@ func TestCheckOptions(t *testing.T) {
t.Fatal("autoredirectpath should be /auth/token") 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 package token
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"math/big"
)
// actionSet is a special type of stringSet. // actionSet is a special type of stringSet.
type actionSet struct { type actionSet struct {
stringSet stringSet
@ -36,3 +46,41 @@ func containsAny(ss []string, q []string) bool {
return false 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)
}