forked from TrueCloudLab/certificates
Merge pull request #133 from smallstep/service-account
kubernetes service account provisioner
This commit is contained in:
commit
ff13b2a699
10 changed files with 654 additions and 3 deletions
|
@ -39,6 +39,7 @@ type loadByTokenPayload struct {
|
||||||
jose.Claims
|
jose.Claims
|
||||||
AuthorizedParty string `json:"azp"` // OIDC client id
|
AuthorizedParty string `json:"azp"` // OIDC client id
|
||||||
TenantID string `json:"tid"` // Microsoft Azure tenant id
|
TenantID string `json:"tid"` // Microsoft Azure tenant id
|
||||||
|
ServiceAccountName string `json:"kubernetes.io/serviceaccount/service-account.name"` // Kubernetes Service Acct Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection is a memory map of provisioners.
|
// Collection is a memory map of provisioners.
|
||||||
|
@ -91,10 +92,21 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims)
|
||||||
if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil {
|
if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
// Audience is required
|
|
||||||
|
// Kubernetes Service Account tokens.
|
||||||
|
if len(payload.ServiceAccountName) > 0 {
|
||||||
|
if p, ok := c.Load(K8sSAID); ok {
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
// Kubernetes service account provisioner not found
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audience is required for non k8sSA tokens.
|
||||||
if len(payload.Audience) == 0 {
|
if len(payload.Audience) == 0 {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try with azp (OIDC)
|
// Try with azp (OIDC)
|
||||||
if len(payload.AuthorizedParty) > 0 {
|
if len(payload.AuthorizedParty) > 0 {
|
||||||
if p, ok := c.Load(payload.AuthorizedParty); ok {
|
if p, ok := c.Load(payload.AuthorizedParty); ok {
|
||||||
|
@ -131,6 +143,8 @@ func (c *Collection) LoadByCertificate(cert *x509.Certificate) (Interface, bool)
|
||||||
return c.Load("acme/" + string(provisioner.Name))
|
return c.Load("acme/" + string(provisioner.Name))
|
||||||
case TypeX5C:
|
case TypeX5C:
|
||||||
return c.Load("x5c/" + string(provisioner.Name))
|
return c.Load("x5c/" + string(provisioner.Name))
|
||||||
|
case TypeK8sSA:
|
||||||
|
return c.Load(K8sSAID)
|
||||||
default:
|
default:
|
||||||
return c.Load(string(provisioner.CredentialID))
|
return c.Load(string(provisioner.CredentialID))
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,13 +59,21 @@ func TestCollection_LoadByToken(t *testing.T) {
|
||||||
assert.FatalError(t, err)
|
assert.FatalError(t, err)
|
||||||
p3, err := generateOIDC()
|
p3, err := generateOIDC()
|
||||||
assert.FatalError(t, err)
|
assert.FatalError(t, err)
|
||||||
|
p4, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
|
||||||
byID := new(sync.Map)
|
byID := new(sync.Map)
|
||||||
byID.Store(p1.GetID(), p1)
|
byID.Store(p1.GetID(), p1)
|
||||||
byID.Store(p2.GetID(), p2)
|
byID.Store(p2.GetID(), p2)
|
||||||
byID.Store(p3.GetID(), p3)
|
byID.Store(p3.GetID(), p3)
|
||||||
|
byID.Store(p4.GetID(), p4)
|
||||||
byID.Store("string", "a-string")
|
byID.Store("string", "a-string")
|
||||||
|
|
||||||
|
byID2 := new(sync.Map)
|
||||||
|
byID2.Store(p1.GetID(), p1)
|
||||||
|
byID2.Store(p2.GetID(), p2)
|
||||||
|
byID2.Store(p3.GetID(), p3)
|
||||||
|
|
||||||
jwk, err := decryptJSONWebKey(p1.EncryptedKey)
|
jwk, err := decryptJSONWebKey(p1.EncryptedKey)
|
||||||
assert.FatalError(t, err)
|
assert.FatalError(t, err)
|
||||||
token, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], jwk)
|
token, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], jwk)
|
||||||
|
@ -90,6 +98,13 @@ func TestCollection_LoadByToken(t *testing.T) {
|
||||||
t4, c4, err := parseToken(token)
|
t4, c4, err := parseToken(token)
|
||||||
assert.FatalError(t, err)
|
assert.FatalError(t, err)
|
||||||
|
|
||||||
|
jwk, err = jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
token, err = generateK8sSAToken(jwk, nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
t5, c5, err := parseToken(token)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
|
||||||
type fields struct {
|
type fields struct {
|
||||||
byID *sync.Map
|
byID *sync.Map
|
||||||
audiences Audiences
|
audiences Audiences
|
||||||
|
@ -108,8 +123,10 @@ func TestCollection_LoadByToken(t *testing.T) {
|
||||||
{"ok1", fields{byID, testAudiences}, args{t1, c1}, p1, true},
|
{"ok1", fields{byID, testAudiences}, args{t1, c1}, p1, true},
|
||||||
{"ok2", fields{byID, testAudiences}, args{t2, c2}, p2, true},
|
{"ok2", fields{byID, testAudiences}, args{t2, c2}, p2, true},
|
||||||
{"ok3", fields{byID, testAudiences}, args{t3, c3}, p3, true},
|
{"ok3", fields{byID, testAudiences}, args{t3, c3}, p3, true},
|
||||||
|
{"ok4", fields{byID, testAudiences}, args{t5, c5}, p4, true},
|
||||||
{"bad", fields{byID, testAudiences}, args{t4, c4}, nil, false},
|
{"bad", fields{byID, testAudiences}, args{t4, c4}, nil, false},
|
||||||
{"fail", fields{byID, Audiences{Sign: []string{"https://foo"}}}, args{t1, c1}, nil, false},
|
{"fail", fields{byID, Audiences{Sign: []string{"https://foo"}}}, args{t1, c1}, nil, false},
|
||||||
|
{"fail-no-k8sSa-provisioner", fields{byID2, testAudiences}, args{t5, c5}, nil, false},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
259
authority/provisioner/k8sSA.go
Normal file
259
authority/provisioner/k8sSA.go
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
package provisioner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/smallstep/cli/crypto/pemutil"
|
||||||
|
"github.com/smallstep/cli/jose"
|
||||||
|
"golang.org/x/crypto/ed25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: There can be at most one kubernetes service account provisioner configured
|
||||||
|
// per instance of step-ca. This is due to a lack of distinguishing information
|
||||||
|
// contained in kubernetes service account tokens.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// K8sSAName is the default name used for kubernetes service account provisioners.
|
||||||
|
K8sSAName = "k8sSA-default"
|
||||||
|
// K8sSAID is the default ID for kubernetes service account provisioners.
|
||||||
|
K8sSAID = "k8ssa/" + K8sSAName
|
||||||
|
k8sSAIssuer = "kubernetes/serviceaccount"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This number must <= 1. We'll verify this in Init() below.
|
||||||
|
var numK8sSAProvisioners = 0
|
||||||
|
|
||||||
|
// jwtPayload extends jwt.Claims with step attributes.
|
||||||
|
type k8sSAPayload struct {
|
||||||
|
jose.Claims
|
||||||
|
Namespace string `json:"kubernetes.io/serviceaccount/namespace,omitempty"`
|
||||||
|
SecretName string `json:"kubernetes.io/serviceaccount/secret.name,omitempty"`
|
||||||
|
ServiceAccountName string `json:"kubernetes.io/serviceaccount/service-account.name,omitempty"`
|
||||||
|
ServiceAccountUID string `json:"kubernetes.io/serviceaccount/service-account.uid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// K8sSA represents a Kubernetes ServiceAccount provisioner; an
|
||||||
|
// entity trusted to make signature requests.
|
||||||
|
type K8sSA struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Claims *Claims `json:"claims,omitempty"`
|
||||||
|
PubKeys []byte `json:"publicKeys,omitempty"`
|
||||||
|
claimer *Claimer
|
||||||
|
audiences Audiences
|
||||||
|
//kauthn kauthn.AuthenticationV1Interface
|
||||||
|
pubKeys []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the provisioner unique identifier. The name and credential id
|
||||||
|
// should uniquely identify any K8sSA provisioner.
|
||||||
|
func (p *K8sSA) GetID() string {
|
||||||
|
return K8sSAID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenID returns an unimplemented error and does not use the input ott.
|
||||||
|
func (p *K8sSA) GetTokenID(ott string) (string, error) {
|
||||||
|
return "", errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the name of the provisioner.
|
||||||
|
func (p *K8sSA) GetName() string {
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetType returns the type of provisioner.
|
||||||
|
func (p *K8sSA) GetType() Type {
|
||||||
|
return TypeK8sSA
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEncryptedKey returns false, because the kubernetes provisioner does not
|
||||||
|
// have access to the private key.
|
||||||
|
func (p *K8sSA) GetEncryptedKey() (string, string, bool) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes and validates the fields of a K8sSA type.
|
||||||
|
func (p *K8sSA) Init(config Config) (err error) {
|
||||||
|
switch {
|
||||||
|
case p.Type == "":
|
||||||
|
return errors.New("provisioner type cannot be empty")
|
||||||
|
case p.Name == "":
|
||||||
|
return errors.New("provisioner name cannot be empty")
|
||||||
|
case numK8sSAProvisioners >= 1:
|
||||||
|
return errors.New("cannot have more than one kubernetes service account provisioner")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.PubKeys != nil {
|
||||||
|
var (
|
||||||
|
block *pem.Block
|
||||||
|
rest = p.PubKeys
|
||||||
|
)
|
||||||
|
for rest != nil {
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
key, err := pemutil.ParseKey(pem.EncodeToMemory(block))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "error parsing public key in provisioner %s", p.GetID())
|
||||||
|
}
|
||||||
|
switch q := key.(type) {
|
||||||
|
case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey:
|
||||||
|
default:
|
||||||
|
return errors.Errorf("Unexpected public key type %T in provisioner %s", q, p.GetID())
|
||||||
|
}
|
||||||
|
p.pubKeys = append(p.pubKeys, key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: Use the TokenReview API if no pub keys provided. This will need to
|
||||||
|
// be configured with additional attributes in the K8sSA struct for
|
||||||
|
// connecting to the kubernetes API server.
|
||||||
|
return errors.New("K8s Service Account provisioner cannot be initialized without pub keys")
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
// NOTE: Not sure if we should be doing this initialization here ...
|
||||||
|
// If you have a k8sSA provisioner defined in your config, but you're not
|
||||||
|
// in a kubernetes pod then your CA will fail to startup. Maybe we just postpone
|
||||||
|
// creating the authn until token validation time?
|
||||||
|
if err := checkAccess(k8s.AuthorizationV1()); err != nil {
|
||||||
|
return errors.Wrapf(err, "error verifying access to kubernetes authz service for provisioner %s", p.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.kauthn = k8s.AuthenticationV1()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Update claims with global ones
|
||||||
|
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.audiences = config.Audiences
|
||||||
|
numK8sSAProvisioners++
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeToken performs common jwt authorization actions and returns the
|
||||||
|
// claims for case specific downstream parsing.
|
||||||
|
// e.g. a Sign request will auth/validate different fields than a Revoke request.
|
||||||
|
func (p *K8sSA) authorizeToken(token string, audiences []string) (*k8sSAPayload, error) {
|
||||||
|
jwt, err := jose.ParseSigned(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "error parsing token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
valid bool
|
||||||
|
claims k8sSAPayload
|
||||||
|
)
|
||||||
|
if p.pubKeys == nil {
|
||||||
|
return nil, errors.New("TokenReview API integration not implemented")
|
||||||
|
/* NOTE: We plan to support the TokenReview API in a future release.
|
||||||
|
Below is some code that should be useful when we prioritize
|
||||||
|
this integration.
|
||||||
|
|
||||||
|
tr := kauthnApi.TokenReview{Spec: kauthnApi.TokenReviewSpec{Token: string(token)}}
|
||||||
|
rvw, err := p.kauthn.TokenReviews().Create(&tr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "error using kubernetes TokenReview API")
|
||||||
|
}
|
||||||
|
if rvw.Status.Error != "" {
|
||||||
|
return nil, errors.Errorf("error from kubernetes TokenReviewAPI: %s", rvw.Status.Error)
|
||||||
|
}
|
||||||
|
if !rvw.Status.Authenticated {
|
||||||
|
return nil, errors.New("error from kubernetes TokenReviewAPI: token could not be authenticated")
|
||||||
|
}
|
||||||
|
if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "error parsing claims")
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
for _, pk := range p.pubKeys {
|
||||||
|
if err = jwt.Claims(pk, &claims); err == nil {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return nil, errors.New("error validating token and extracting claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// According to "rfc7519 JSON Web Token" acceptable skew should be no
|
||||||
|
// more than a few minutes.
|
||||||
|
if err = claims.Validate(jose.Expected{
|
||||||
|
Issuer: k8sSAIssuer,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Subject == "" {
|
||||||
|
return nil, errors.New("token subject cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeRevoke returns an error if the provisioner does not have rights to
|
||||||
|
// revoke the certificate with serial number in the `sub` property.
|
||||||
|
func (p *K8sSA) AuthorizeRevoke(token string) error {
|
||||||
|
_, err := p.authorizeToken(token, p.audiences.Revoke)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeSign validates the given token.
|
||||||
|
func (p *K8sSA) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) {
|
||||||
|
_, err := p.authorizeToken(token, p.audiences.Sign)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for SSH sign-ing request.
|
||||||
|
if MethodFromContext(ctx) == SignSSHMethod {
|
||||||
|
return nil, errors.New("ssh certificates not enabled for k8s ServiceAccount provisioners")
|
||||||
|
}
|
||||||
|
|
||||||
|
return []SignOption{
|
||||||
|
// modifiers / withOptions
|
||||||
|
newProvisionerExtensionOption(TypeK8sSA, p.Name, ""),
|
||||||
|
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
|
||||||
|
// validators
|
||||||
|
defaultPublicKeyValidator{},
|
||||||
|
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeRenewal returns an error if the renewal is disabled.
|
||||||
|
func (p *K8sSA) AuthorizeRenewal(cert *x509.Certificate) error {
|
||||||
|
if p.claimer.IsDisableRenewal() {
|
||||||
|
return errors.Errorf("renew is disabled for provisioner %s", p.GetID())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
func checkAccess(authz kauthz.AuthorizationV1Interface) error {
|
||||||
|
r := &kauthzApi.SelfSubjectAccessReview{
|
||||||
|
Spec: kauthzApi.SelfSubjectAccessReviewSpec{
|
||||||
|
ResourceAttributes: &kauthzApi.ResourceAttributes{
|
||||||
|
Group: "authentication.k8s.io",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "tokenreviews",
|
||||||
|
Verb: "create",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rvw, err := authz.SelfSubjectAccessReviews().Create(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !rvw.Status.Allowed {
|
||||||
|
return fmt.Errorf("Unable to create kubernetes token reviews: %s", rvw.Status.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*/
|
264
authority/provisioner/k8sSA_test.go
Normal file
264
authority/provisioner/k8sSA_test.go
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
package provisioner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/smallstep/assert"
|
||||||
|
"github.com/smallstep/cli/jose"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestK8sSA_Getters(t *testing.T) {
|
||||||
|
p, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
id := "k8ssa/" + p.Name
|
||||||
|
if got := p.GetID(); got != id {
|
||||||
|
t.Errorf("K8sSA.GetID() = %v, want %v", got, id)
|
||||||
|
}
|
||||||
|
if got := p.GetName(); got != p.Name {
|
||||||
|
t.Errorf("K8sSA.GetName() = %v, want %v", got, p.Name)
|
||||||
|
}
|
||||||
|
if got := p.GetType(); got != TypeK8sSA {
|
||||||
|
t.Errorf("K8sSA.GetType() = %v, want %v", got, TypeK8sSA)
|
||||||
|
}
|
||||||
|
kid, key, ok := p.GetEncryptedKey()
|
||||||
|
if kid != "" || key != "" || ok == true {
|
||||||
|
t.Errorf("K8sSA.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)",
|
||||||
|
kid, key, ok, "", "", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sSA_authorizeToken(t *testing.T) {
|
||||||
|
type test struct {
|
||||||
|
p *K8sSA
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
tests := map[string]func(*testing.T) test{
|
||||||
|
"fail/bad-token": func(t *testing.T) test {
|
||||||
|
p, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: "foo",
|
||||||
|
err: errors.New("error parsing token"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fail/error-validating-token": func(t *testing.T) test {
|
||||||
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
tok, err := generateToken("", p.Name, testAudiences.Sign[0], "",
|
||||||
|
[]string{"test.smallstep.com"}, time.Now(), jwk)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: tok,
|
||||||
|
err: errors.New("error validating token and extracting claims"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fail/invalid-issuer": func(t *testing.T) test {
|
||||||
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p, err := generateK8sSA(jwk.Public().Key)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
claims := getK8sSAPayload()
|
||||||
|
claims.Claims.Issuer = "invalid"
|
||||||
|
tok, err := generateK8sSAToken(jwk, claims)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: tok,
|
||||||
|
err: errors.New("invalid token claims: square/go-jose/jwt: validation failed, invalid issuer claim (iss)"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ok": func(t *testing.T) test {
|
||||||
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p, err := generateK8sSA(jwk.Public().Key)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
tok, err := generateK8sSAToken(jwk, nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: tok,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
tc := tt(t)
|
||||||
|
if claims, err := tc.p.authorizeToken(tc.token, testAudiences.Sign); err != nil {
|
||||||
|
if assert.NotNil(t, tc.err) {
|
||||||
|
assert.HasPrefix(t, err.Error(), tc.err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if assert.Nil(t, tc.err) {
|
||||||
|
assert.NotNil(t, claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sSA_AuthorizeSign(t *testing.T) {
|
||||||
|
type test struct {
|
||||||
|
p *K8sSA
|
||||||
|
token string
|
||||||
|
ctx context.Context
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
tests := map[string]func(*testing.T) test{
|
||||||
|
"fail/invalid-token": func(t *testing.T) test {
|
||||||
|
p, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: "foo",
|
||||||
|
err: errors.New("error parsing token"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fail/ssh-unimplemented": func(t *testing.T) test {
|
||||||
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p, err := generateK8sSA(jwk.Public().Key)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
tok, err := generateK8sSAToken(jwk, nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
ctx: NewContextWithMethod(context.Background(), SignSSHMethod),
|
||||||
|
token: tok,
|
||||||
|
err: errors.Errorf("ssh certificates not enabled for k8s ServiceAccount provisioners"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ok": func(t *testing.T) test {
|
||||||
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p, err := generateK8sSA(jwk.Public().Key)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
tok, err := generateK8sSAToken(jwk, nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
ctx: NewContextWithMethod(context.Background(), SignMethod),
|
||||||
|
token: tok,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
tc := tt(t)
|
||||||
|
if opts, err := tc.p.AuthorizeSign(tc.ctx, tc.token); err != nil {
|
||||||
|
if assert.NotNil(t, tc.err) {
|
||||||
|
assert.HasPrefix(t, err.Error(), tc.err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if assert.Nil(t, tc.err) {
|
||||||
|
if assert.NotNil(t, opts) {
|
||||||
|
tot := 0
|
||||||
|
for _, o := range opts {
|
||||||
|
switch v := o.(type) {
|
||||||
|
case *provisionerExtensionOption:
|
||||||
|
assert.Equals(t, v.Type, int(TypeK8sSA))
|
||||||
|
assert.Equals(t, v.Name, tc.p.GetName())
|
||||||
|
assert.Equals(t, v.CredentialID, "")
|
||||||
|
assert.Len(t, 0, v.KeyValuePairs)
|
||||||
|
case profileDefaultDuration:
|
||||||
|
assert.Equals(t, time.Duration(v), tc.p.claimer.DefaultTLSCertDuration())
|
||||||
|
case defaultPublicKeyValidator:
|
||||||
|
case *validityValidator:
|
||||||
|
assert.Equals(t, v.min, tc.p.claimer.MinTLSCertDuration())
|
||||||
|
assert.Equals(t, v.max, tc.p.claimer.MaxTLSCertDuration())
|
||||||
|
default:
|
||||||
|
assert.FatalError(t, errors.Errorf("unexpected sign option of type %T", v))
|
||||||
|
}
|
||||||
|
tot++
|
||||||
|
}
|
||||||
|
assert.Equals(t, tot, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sSA_AuthorizeRevoke(t *testing.T) {
|
||||||
|
type test struct {
|
||||||
|
p *K8sSA
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
tests := map[string]func(*testing.T) test{
|
||||||
|
"fail/invalid-token": func(t *testing.T) test {
|
||||||
|
p, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: "foo",
|
||||||
|
err: errors.New("error parsing token"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ok": func(t *testing.T) test {
|
||||||
|
jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p, err := generateK8sSA(jwk.Public().Key)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
tok, err := generateK8sSAToken(jwk, nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
return test{
|
||||||
|
p: p,
|
||||||
|
token: tok,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
tc := tt(t)
|
||||||
|
if err := tc.p.AuthorizeRevoke(tc.token); err != nil {
|
||||||
|
if assert.NotNil(t, tc.err) {
|
||||||
|
assert.HasPrefix(t, err.Error(), tc.err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, tc.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sSA_AuthorizeRenewal(t *testing.T) {
|
||||||
|
p1, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
p2, err := generateK8sSA(nil)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
|
||||||
|
// disable renewal
|
||||||
|
disable := true
|
||||||
|
p2.Claims = &Claims{DisableRenewal: &disable}
|
||||||
|
p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims)
|
||||||
|
assert.FatalError(t, err)
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
cert *x509.Certificate
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prov *K8sSA
|
||||||
|
args args
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"ok", p1, args{nil}, false},
|
||||||
|
{"fail", p2, args{nil}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.prov.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("X5C.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -88,6 +88,8 @@ const (
|
||||||
TypeACME Type = 6
|
TypeACME Type = 6
|
||||||
// TypeX5C is used to indicate the X5C provisioners.
|
// TypeX5C is used to indicate the X5C provisioners.
|
||||||
TypeX5C Type = 7
|
TypeX5C Type = 7
|
||||||
|
// TypeK8sSA is used to indicate the X5C provisioners.
|
||||||
|
TypeK8sSA Type = 8
|
||||||
|
|
||||||
// RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map.
|
// RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map.
|
||||||
RevokeAudienceKey = "revoke"
|
RevokeAudienceKey = "revoke"
|
||||||
|
@ -112,6 +114,8 @@ func (t Type) String() string {
|
||||||
return "ACME"
|
return "ACME"
|
||||||
case TypeX5C:
|
case TypeX5C:
|
||||||
return "X5C"
|
return "X5C"
|
||||||
|
case TypeK8sSA:
|
||||||
|
return "K8sSA"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@ -163,6 +167,8 @@ func (l *List) UnmarshalJSON(data []byte) error {
|
||||||
p = &ACME{}
|
p = &ACME{}
|
||||||
case "x5c":
|
case "x5c":
|
||||||
p = &X5C{}
|
p = &X5C{}
|
||||||
|
case "k8ssa":
|
||||||
|
p = &K8sSA{}
|
||||||
default:
|
default:
|
||||||
// Skip unsupported provisioners. A client using this method may be
|
// Skip unsupported provisioners. A client using this method may be
|
||||||
// compiled with a version of smallstep/certificates that does not
|
// compiled with a version of smallstep/certificates that does not
|
||||||
|
|
5
authority/provisioner/testdata/bar.priv
vendored
Normal file
5
authority/provisioner/testdata/bar.priv
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIM8wGIzCKjAOGdBFmYHtS791Ly2I9FtmknEsR2sa63s7oAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEGQIXbsr73X28pzwC1wa+ccY2H3s8PplbkapCrwxyYVvM78y/GmeS
|
||||||
|
A7fv2MKQ8iKpCw461MlOQGX+VlWT+ChRFw==
|
||||||
|
-----END EC PRIVATE KEY-----
|
4
authority/provisioner/testdata/bar.pub
vendored
Normal file
4
authority/provisioner/testdata/bar.pub
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGQIXbsr73X28pzwC1wa+ccY2H3s8
|
||||||
|
PplbkapCrwxyYVvM78y/GmeSA7fv2MKQ8iKpCw461MlOQGX+VlWT+ChRFw==
|
||||||
|
-----END PUBLIC KEY-----
|
5
authority/provisioner/testdata/foo.priv
vendored
Normal file
5
authority/provisioner/testdata/foo.priv
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIB8ovUg0Atvz+b+XiF8QV722OivOm1geGtI3sP0F48N1oAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEzriaeV2e1aEz33x62kyqVC6ootU7rl41L8cyeOJ4SjTu4FV+o5i4
|
||||||
|
NsS6DCE07JJSHlqc9PsrzjSs4LZD4gWVLQ==
|
||||||
|
-----END EC PRIVATE KEY-----
|
4
authority/provisioner/testdata/foo.pub
vendored
Normal file
4
authority/provisioner/testdata/foo.pub
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzriaeV2e1aEz33x62kyqVC6ootU7
|
||||||
|
rl41L8cyeOJ4SjTu4FV+o5i4NsS6DCE07JJSHlqc9PsrzjSs4LZD4gWVLQ==
|
||||||
|
-----END PUBLIC KEY-----
|
|
@ -10,11 +10,13 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/smallstep/cli/crypto/pemutil"
|
||||||
"github.com/smallstep/cli/crypto/randutil"
|
"github.com/smallstep/cli/crypto/randutil"
|
||||||
"github.com/smallstep/cli/jose"
|
"github.com/smallstep/cli/jose"
|
||||||
)
|
)
|
||||||
|
@ -197,6 +199,43 @@ func generateJWK() (*JWK, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateK8sSA(inputPubKey interface{}) (*K8sSA, error) {
|
||||||
|
fooPubB, err := ioutil.ReadFile("./testdata/foo.pub")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fooPub, err := pemutil.ParseKey(fooPubB)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
barPubB, err := ioutil.ReadFile("./testdata/bar.pub")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
barPub, err := pemutil.ParseKey(barPubB)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claimer, err := NewClaimer(nil, globalProvisionerClaims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pubKeys := []interface{}{fooPub, barPub}
|
||||||
|
if inputPubKey != nil {
|
||||||
|
pubKeys = append(pubKeys, inputPubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &K8sSA{
|
||||||
|
Name: K8sSAName,
|
||||||
|
Type: "K8sSA",
|
||||||
|
Claims: &globalProvisionerClaims,
|
||||||
|
audiences: testAudiences,
|
||||||
|
claimer: claimer,
|
||||||
|
pubKeys: pubKeys,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func generateX5C(root []byte) (*X5C, error) {
|
func generateX5C(root []byte) (*X5C, error) {
|
||||||
if root == nil {
|
if root == nil {
|
||||||
root = []byte(`-----BEGIN CERTIFICATE-----
|
root = []byte(`-----BEGIN CERTIFICATE-----
|
||||||
|
@ -587,6 +626,40 @@ func generateToken(sub, iss, aud string, email string, sans []string, iat time.T
|
||||||
return jose.Signed(sig).Claims(claims).CompactSerialize()
|
return jose.Signed(sig).Claims(claims).CompactSerialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getK8sSAPayload() *k8sSAPayload {
|
||||||
|
return &k8sSAPayload{
|
||||||
|
Claims: jose.Claims{
|
||||||
|
Issuer: k8sSAIssuer,
|
||||||
|
Subject: "foo",
|
||||||
|
},
|
||||||
|
Namespace: "ns-foo",
|
||||||
|
SecretName: "sn-foo",
|
||||||
|
ServiceAccountName: "san-foo",
|
||||||
|
ServiceAccountUID: "sauid-foo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateK8sSAToken(jwk *jose.JSONWebKey, claims *k8sSAPayload, tokOpts ...tokOption) (string, error) {
|
||||||
|
so := new(jose.SignerOptions)
|
||||||
|
so.WithHeader("kid", jwk.KeyID)
|
||||||
|
|
||||||
|
for _, o := range tokOpts {
|
||||||
|
if err := o(so); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, so)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims == nil {
|
||||||
|
claims = getK8sSAPayload()
|
||||||
|
}
|
||||||
|
return jose.Signed(sig).Claims(*claims).CompactSerialize()
|
||||||
|
}
|
||||||
|
|
||||||
func generateSimpleSSHUserToken(iss, aud string, jwk *jose.JSONWebKey) (string, error) {
|
func generateSimpleSSHUserToken(iss, aud string, jwk *jose.JSONWebKey) (string, error) {
|
||||||
return generateSSHToken("subject@localhost", iss, aud, time.Now(), &SSHOptions{
|
return generateSSHToken("subject@localhost", iss, aud, time.Now(), &SSHOptions{
|
||||||
CertType: "user",
|
CertType: "user",
|
||||||
|
|
Loading…
Reference in a new issue