fe21f43911
docker/libtrust repository has been archived for several years now. This commit replaces all the libtrust JWT machinery with go-jose/go-jose module. Some of the code has been adopted from libtrust and adjusted for some of the use cases covered by the token authorization flow especially in the tests. Signed-off-by: Milos Gajdos <milosthegajdos@gmail.com>
655 lines
16 KiB
Go
655 lines
16 KiB
Go
package token
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/context"
|
|
"github.com/distribution/distribution/v3/registry/auth"
|
|
"github.com/go-jose/go-jose/v3"
|
|
"github.com/go-jose/go-jose/v3/jwt"
|
|
)
|
|
|
|
func makeRootKeys(numKeys int) ([]*ecdsa.PrivateKey, error) {
|
|
rootKeys := make([]*ecdsa.PrivateKey, 0, numKeys)
|
|
|
|
for i := 0; i < numKeys; i++ {
|
|
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rootKeys = append(rootKeys, pk)
|
|
}
|
|
|
|
return rootKeys, nil
|
|
}
|
|
|
|
func makeRootCerts(rootKeys []*ecdsa.PrivateKey) ([]*x509.Certificate, error) {
|
|
rootCerts := make([]*x509.Certificate, 0, len(rootKeys))
|
|
|
|
for _, rootKey := range rootKeys {
|
|
cert, err := generateCACert(rootKey, rootKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rootCerts = append(rootCerts, cert)
|
|
}
|
|
|
|
return rootCerts, nil
|
|
}
|
|
|
|
func makeSigningKeyWithChain(rootKey *ecdsa.PrivateKey, depth int) (*jose.JSONWebKey, error) {
|
|
if depth == 0 {
|
|
// Don't need to build a chain.
|
|
return &jose.JSONWebKey{
|
|
Key: rootKey,
|
|
KeyID: rootKey.X.String(),
|
|
Algorithm: string(jose.ES256),
|
|
}, nil
|
|
}
|
|
|
|
var (
|
|
certs = make([]*x509.Certificate, depth)
|
|
parentKey = rootKey
|
|
|
|
pk *ecdsa.PrivateKey
|
|
cert *x509.Certificate
|
|
err error
|
|
)
|
|
|
|
for depth > 0 {
|
|
if pk, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if cert, err = generateCACert(parentKey, pk); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
depth--
|
|
certs[depth] = cert
|
|
parentKey = pk
|
|
}
|
|
|
|
return &jose.JSONWebKey{
|
|
Key: parentKey,
|
|
KeyID: rootKey.X.String(),
|
|
Algorithm: string(jose.ES256),
|
|
Certificates: certs,
|
|
}, nil
|
|
}
|
|
|
|
func makeTestToken(jwk *jose.JSONWebKey, issuer, audience string, access []*ResourceActions, now time.Time, exp time.Time) (*Token, error) {
|
|
signingKey := jose.SigningKey{
|
|
Algorithm: jose.ES256,
|
|
Key: jwk,
|
|
}
|
|
signerOpts := jose.SignerOptions{
|
|
EmbedJWK: true,
|
|
}
|
|
signerOpts.WithType("JWT")
|
|
|
|
signer, err := jose.NewSigner(signingKey, &signerOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create a signer: %s", err)
|
|
}
|
|
|
|
randomBytes := make([]byte, 15)
|
|
if _, err = rand.Read(randomBytes); err != nil {
|
|
return nil, fmt.Errorf("unable to read random bytes for jwt id: %s", err)
|
|
}
|
|
|
|
claimSet := &ClaimSet{
|
|
Issuer: issuer,
|
|
Subject: "foo",
|
|
Audience: []string{audience},
|
|
Expiration: exp.Unix(),
|
|
NotBefore: now.Unix(),
|
|
IssuedAt: now.Unix(),
|
|
JWTID: base64.URLEncoding.EncodeToString(randomBytes),
|
|
Access: access,
|
|
}
|
|
|
|
tokenString, err := jwt.Signed(signer).Claims(claimSet).CompactSerialize()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to build token string: %v", err)
|
|
}
|
|
|
|
return NewToken(tokenString)
|
|
}
|
|
|
|
// NOTE(milosgajdos): certTemplateInfo type as well
|
|
// as some of the functions in this file have been
|
|
// adopted from https://github.com/docker/libtrust
|
|
// and modiified to fit the purpose of the token package.
|
|
|
|
type certTemplateInfo struct {
|
|
commonName string
|
|
domains []string
|
|
ipAddresses []net.IP
|
|
isCA bool
|
|
clientAuth bool
|
|
serverAuth bool
|
|
}
|
|
|
|
func generateCertTemplate(info *certTemplateInfo) *x509.Certificate {
|
|
// Generate a certificate template which is valid from the past week to
|
|
// 10 years from now. The usage of the certificate depends on the
|
|
// specified fields in the given certTempInfo object.
|
|
var (
|
|
keyUsage x509.KeyUsage
|
|
extKeyUsage []x509.ExtKeyUsage
|
|
)
|
|
|
|
if info.isCA {
|
|
keyUsage = x509.KeyUsageCertSign
|
|
}
|
|
|
|
if info.clientAuth {
|
|
extKeyUsage = append(extKeyUsage, x509.ExtKeyUsageClientAuth)
|
|
}
|
|
|
|
if info.serverAuth {
|
|
extKeyUsage = append(extKeyUsage, x509.ExtKeyUsageServerAuth)
|
|
}
|
|
|
|
return &x509.Certificate{
|
|
SerialNumber: big.NewInt(0),
|
|
Subject: pkix.Name{
|
|
CommonName: info.commonName,
|
|
},
|
|
NotBefore: time.Now().Add(-time.Hour * 24 * 7),
|
|
NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10),
|
|
DNSNames: info.domains,
|
|
IPAddresses: info.ipAddresses,
|
|
IsCA: info.isCA,
|
|
KeyUsage: keyUsage,
|
|
ExtKeyUsage: extKeyUsage,
|
|
BasicConstraintsValid: info.isCA,
|
|
}
|
|
}
|
|
|
|
func generateCert(priv crypto.PrivateKey, pub crypto.PublicKey, subInfo, issInfo *certTemplateInfo) (*x509.Certificate, error) {
|
|
pubCertTemplate := generateCertTemplate(subInfo)
|
|
privCertTemplate := generateCertTemplate(issInfo)
|
|
|
|
certDER, err := x509.CreateCertificate(
|
|
rand.Reader, pubCertTemplate, privCertTemplate,
|
|
pub, priv,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create certificate: %s", err)
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate: %s", err)
|
|
}
|
|
|
|
return cert, nil
|
|
}
|
|
|
|
// generateCACert creates a certificate which can be used as a trusted
|
|
// certificate authority.
|
|
func generateCACert(signer *ecdsa.PrivateKey, trustedKey *ecdsa.PrivateKey) (*x509.Certificate, error) {
|
|
subjectInfo := &certTemplateInfo{
|
|
commonName: trustedKey.X.String(),
|
|
isCA: true,
|
|
}
|
|
issuerInfo := &certTemplateInfo{
|
|
commonName: signer.X.String(),
|
|
}
|
|
|
|
return generateCert(signer, trustedKey.Public(), subjectInfo, issuerInfo)
|
|
}
|
|
|
|
// This test makes 4 tokens with a varying number of intermediate
|
|
// certificates ranging from no intermediate chain to a length of 3
|
|
// intermediates.
|
|
func TestTokenVerify(t *testing.T) {
|
|
var (
|
|
numTokens = 4
|
|
issuer = "test-issuer"
|
|
audience = "test-audience"
|
|
access = []*ResourceActions{
|
|
{
|
|
Type: "repository",
|
|
Name: "foo/bar",
|
|
Actions: []string{"pull", "push"},
|
|
},
|
|
}
|
|
)
|
|
|
|
rootKeys, err := makeRootKeys(numTokens)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rootCerts, err := makeRootCerts(rootKeys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rootPool := x509.NewCertPool()
|
|
for _, rootCert := range rootCerts {
|
|
rootPool.AddCert(rootCert)
|
|
}
|
|
|
|
tokens := make([]*Token, 0, numTokens)
|
|
trustedKeys := map[string]crypto.PublicKey{}
|
|
|
|
for i := 0; i < numTokens; i++ {
|
|
jwk, err := makeSigningKeyWithChain(rootKeys[i], i)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// add to trusted keys
|
|
trustedKeys[jwk.KeyID] = jwk.Public()
|
|
token, err := makeTestToken(jwk, issuer, audience, access, time.Now(), time.Now().Add(5*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tokens = append(tokens, token)
|
|
}
|
|
|
|
verifyOps := VerifyOptions{
|
|
TrustedIssuers: []string{issuer},
|
|
AcceptedAudiences: []string{audience},
|
|
Roots: rootPool,
|
|
TrustedKeys: trustedKeys,
|
|
}
|
|
|
|
for _, token := range tokens {
|
|
if _, err := token.Verify(verifyOps); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// This tests that we don't fail tokens with nbf within
|
|
// the defined leeway in seconds
|
|
func TestLeeway(t *testing.T) {
|
|
var (
|
|
issuer = "test-issuer"
|
|
audience = "test-audience"
|
|
access = []*ResourceActions{
|
|
{
|
|
Type: "repository",
|
|
Name: "foo/bar",
|
|
Actions: []string{"pull", "push"},
|
|
},
|
|
}
|
|
)
|
|
|
|
rootKeys, err := makeRootKeys(1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
jwk, err := makeSigningKeyWithChain(rootKeys[0], 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
trustedKeys := map[string]crypto.PublicKey{
|
|
jwk.KeyID: jwk.Public(),
|
|
}
|
|
|
|
verifyOps := VerifyOptions{
|
|
TrustedIssuers: []string{issuer},
|
|
AcceptedAudiences: []string{audience},
|
|
Roots: nil,
|
|
TrustedKeys: trustedKeys,
|
|
}
|
|
|
|
// nbf verification should pass within leeway
|
|
futureNow := time.Now().Add(time.Duration(5) * time.Second)
|
|
token, err := makeTestToken(jwk, issuer, audience, access, futureNow, futureNow.Add(5*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := token.Verify(verifyOps); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// nbf verification should fail with a skew larger than leeway
|
|
futureNow = time.Now().Add(time.Duration(61) * time.Second)
|
|
token, err = makeTestToken(jwk, issuer, audience, access, futureNow, futureNow.Add(5*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err = token.Verify(verifyOps); err == nil {
|
|
t.Fatal("Verification should fail for token with nbf in the future outside leeway")
|
|
}
|
|
|
|
// exp verification should pass within leeway
|
|
token, err = makeTestToken(jwk, issuer, audience, access, time.Now(), time.Now().Add(-59*time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err = token.Verify(verifyOps); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// exp verification should fail with a skew larger than leeway
|
|
token, err = makeTestToken(jwk, issuer, audience, access, time.Now(), time.Now().Add(-60*time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err = token.Verify(verifyOps); err == nil {
|
|
t.Fatal("Verification should fail for token with exp in the future outside leeway")
|
|
}
|
|
}
|
|
|
|
func writeTempRootCerts(rootKeys []*ecdsa.PrivateKey) (filename string, err error) {
|
|
rootCerts, err := makeRootCerts(rootKeys)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tempFile, err := os.CreateTemp("", "rootCertBundle")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tempFile.Close()
|
|
|
|
for _, cert := range rootCerts {
|
|
if err = pem.Encode(tempFile, &pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: cert.Raw,
|
|
}); err != nil {
|
|
os.Remove(tempFile.Name())
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return tempFile.Name(), nil
|
|
}
|
|
|
|
func writeTempJWKS(rootKeys []*ecdsa.PrivateKey) (filename string, err error) {
|
|
keys := make([]jose.JSONWebKey, len(rootKeys))
|
|
for i := range rootKeys {
|
|
jwk, err := makeSigningKeyWithChain(rootKeys[i], i)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
keys[i] = *jwk
|
|
}
|
|
jwks := jose.JSONWebKeySet{
|
|
Keys: keys,
|
|
}
|
|
tempFile, err := os.CreateTemp("", "jwksBundle")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tempFile.Close()
|
|
|
|
if err := json.NewEncoder(tempFile).Encode(jwks); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return tempFile.Name(), nil
|
|
}
|
|
|
|
// TestAccessController tests complete integration of the token auth package.
|
|
// It starts by mocking the options for a token auth accessController which
|
|
// it creates. It then tries a few mock requests:
|
|
// - don't supply a token; should error with challenge
|
|
// - supply an invalid token; should error with challenge
|
|
// - supply a token with insufficient access; should error with challenge
|
|
// - supply a valid token; should not error
|
|
func TestAccessController(t *testing.T) {
|
|
// Make 2 keys; only the first is to be a trusted root key.
|
|
rootKeys, err := makeRootKeys(2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rootCertBundleFilename, err := writeTempRootCerts(rootKeys[:1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(rootCertBundleFilename)
|
|
|
|
jwksFilename, err := writeTempJWKS(rootKeys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
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": rootCertBundleFilename,
|
|
"autoredirect": false,
|
|
"jwks": jwksFilename,
|
|
}
|
|
|
|
accessController, err := newAccessController(options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 1. Make a mock http.Request with no token.
|
|
req, err := http.NewRequest(http.MethodGet, "http://example.com/foo", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
testAccess := auth.Access{
|
|
Resource: auth.Resource{
|
|
Type: "foo",
|
|
Name: "bar",
|
|
},
|
|
Action: "baz",
|
|
}
|
|
|
|
ctx := context.WithRequest(context.Background(), req)
|
|
authCtx, err := accessController.Authorized(ctx, testAccess)
|
|
challenge, ok := err.(auth.Challenge)
|
|
if !ok {
|
|
t.Fatal("accessController did not return a challenge")
|
|
}
|
|
|
|
if challenge.Error() != ErrTokenRequired.Error() {
|
|
t.Fatalf("accessControler did not get expected error - got %s - expected %s", challenge, ErrTokenRequired)
|
|
}
|
|
|
|
if authCtx != nil {
|
|
t.Fatalf("expected nil auth context but got %s", authCtx)
|
|
}
|
|
|
|
// 2. Supply an invalid token.
|
|
invalidJwk, err := makeSigningKeyWithChain(rootKeys[1], 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
token, err := makeTestToken(
|
|
invalidJwk, issuer, service,
|
|
[]*ResourceActions{{
|
|
Type: testAccess.Type,
|
|
Name: testAccess.Name,
|
|
Actions: []string{testAccess.Action},
|
|
}},
|
|
time.Now(), time.Now().Add(5*time.Minute), // Everything is valid except the key which signed it.
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.Raw))
|
|
|
|
authCtx, err = accessController.Authorized(ctx, testAccess)
|
|
challenge, ok = err.(auth.Challenge)
|
|
if !ok {
|
|
t.Fatal("accessController did not return a challenge")
|
|
}
|
|
|
|
if challenge.Error() != ErrInvalidToken.Error() {
|
|
t.Fatalf("accessControler did not get expected error - got %s - expected %s", challenge, ErrTokenRequired)
|
|
}
|
|
|
|
if authCtx != nil {
|
|
t.Fatalf("expected nil auth context but got %s", authCtx)
|
|
}
|
|
|
|
// create a valid jwk
|
|
jwk, err := makeSigningKeyWithChain(rootKeys[0], 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 3. Supply a token with insufficient access.
|
|
token, err = makeTestToken(
|
|
jwk, issuer, service,
|
|
[]*ResourceActions{}, // No access specified.
|
|
time.Now(), time.Now().Add(5*time.Minute),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.Raw))
|
|
|
|
authCtx, err = accessController.Authorized(ctx, testAccess)
|
|
challenge, ok = err.(auth.Challenge)
|
|
if !ok {
|
|
t.Fatal("accessController did not return a challenge")
|
|
}
|
|
|
|
if challenge.Error() != ErrInsufficientScope.Error() {
|
|
t.Fatalf("accessControler did not get expected error - got %s - expected %s", challenge, ErrInsufficientScope)
|
|
}
|
|
|
|
if authCtx != nil {
|
|
t.Fatalf("expected nil auth context but got %s", authCtx)
|
|
}
|
|
|
|
// 4. Supply the token we need, or deserve, or whatever.
|
|
token, err = makeTestToken(
|
|
jwk, issuer, service,
|
|
[]*ResourceActions{{
|
|
Type: testAccess.Type,
|
|
Name: testAccess.Name,
|
|
Actions: []string{testAccess.Action},
|
|
}},
|
|
time.Now(), time.Now().Add(5*time.Minute),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.Raw))
|
|
|
|
authCtx, err = accessController.Authorized(ctx, testAccess)
|
|
if err != nil {
|
|
t.Fatalf("accessController returned unexpected error: %s", err)
|
|
}
|
|
|
|
userInfo, ok := authCtx.Value(auth.UserKey).(auth.UserInfo)
|
|
if !ok {
|
|
t.Fatal("token accessController did not set auth.user context")
|
|
}
|
|
|
|
if userInfo.Name != "foo" {
|
|
t.Fatalf("expected user name %q, got %q", "foo", userInfo.Name)
|
|
}
|
|
|
|
// 5. Supply a token with full admin rights, which is represented as "*".
|
|
token, err = makeTestToken(
|
|
jwk, issuer, service,
|
|
[]*ResourceActions{{
|
|
Type: testAccess.Type,
|
|
Name: testAccess.Name,
|
|
Actions: []string{"*"},
|
|
}},
|
|
time.Now(), time.Now().Add(5*time.Minute),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.Raw))
|
|
|
|
_, err = accessController.Authorized(ctx, testAccess)
|
|
if err != nil {
|
|
t.Fatalf("accessController returned unexpected error: %s", err)
|
|
}
|
|
}
|
|
|
|
// This tests that newAccessController can handle PEM blocks in the certificate
|
|
// file other than certificates, for example a private key.
|
|
func TestNewAccessControllerPemBlock(t *testing.T) {
|
|
rootKeys, err := makeRootKeys(2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rootCertBundleFilename, err := writeTempRootCerts(rootKeys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(rootCertBundleFilename)
|
|
|
|
// Add something other than a certificate to the rootcertbundle
|
|
file, err := os.OpenFile(rootCertBundleFilename, os.O_WRONLY|os.O_APPEND, 0o666)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bytes, err := x509.MarshalECPrivateKey(rootKeys[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = file.Write(bytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = file.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
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": rootCertBundleFilename,
|
|
"autoredirect": false,
|
|
}
|
|
|
|
ac, err := newAccessController(options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(ac.(*accessController).rootCerts.Subjects()) != 2 { //nolint:staticcheck // FIXME(thaJeztah): ignore SA1019: ac.(*accessController).rootCerts.Subjects has been deprecated since Go 1.18: if s was returned by SystemCertPool, Subjects will not include the system roots. (staticcheck)
|
|
t.Fatal("accessController has the wrong number of certificates")
|
|
}
|
|
}
|