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:
parent
b74618692d
commit
b53946ded3
@ -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)
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user