From 8f07ff6a39713c01b42da257d2f3575aa010fac4 Mon Sep 17 00:00:00 2001 From: max furman Date: Fri, 18 Oct 2019 13:52:28 -0700 Subject: [PATCH] Add kubernetes service account provisioner --- authority/provisioner/collection.go | 20 +- authority/provisioner/collection_test.go | 17 ++ authority/provisioner/k8sSA.go | 259 ++++++++++++++++++++++ authority/provisioner/k8sSA_test.go | 264 +++++++++++++++++++++++ authority/provisioner/provisioner.go | 6 + authority/provisioner/testdata/bar.priv | 5 + authority/provisioner/testdata/bar.pub | 4 + authority/provisioner/testdata/foo.priv | 5 + authority/provisioner/testdata/foo.pub | 4 + authority/provisioner/utils_test.go | 73 +++++++ 10 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 authority/provisioner/k8sSA.go create mode 100644 authority/provisioner/k8sSA_test.go create mode 100644 authority/provisioner/testdata/bar.priv create mode 100644 authority/provisioner/testdata/bar.pub create mode 100644 authority/provisioner/testdata/foo.priv create mode 100644 authority/provisioner/testdata/foo.pub diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index 0d14a65b..bf189ee5 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -37,8 +37,9 @@ func (p provisionerSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } // provisioner. type loadByTokenPayload struct { jose.Claims - AuthorizedParty string `json:"azp"` // OIDC client id - TenantID string `json:"tid"` // Microsoft Azure tenant id + AuthorizedParty string `json:"azp"` // OIDC client 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. @@ -91,10 +92,21 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil { 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 { return nil, false } + // Try with azp (OIDC) if len(payload.AuthorizedParty) > 0 { 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)) case TypeX5C: return c.Load("x5c/" + string(provisioner.Name)) + case TypeK8sSA: + return c.Load(K8sSAID) default: return c.Load(string(provisioner.CredentialID)) } diff --git a/authority/provisioner/collection_test.go b/authority/provisioner/collection_test.go index b06a27c5..cd15c18c 100644 --- a/authority/provisioner/collection_test.go +++ b/authority/provisioner/collection_test.go @@ -59,13 +59,21 @@ func TestCollection_LoadByToken(t *testing.T) { assert.FatalError(t, err) p3, err := generateOIDC() assert.FatalError(t, err) + p4, err := generateK8sSA(nil) + assert.FatalError(t, err) byID := new(sync.Map) byID.Store(p1.GetID(), p1) byID.Store(p2.GetID(), p2) byID.Store(p3.GetID(), p3) + byID.Store(p4.GetID(), p4) 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) assert.FatalError(t, err) token, err := generateSimpleToken(p1.Name, testAudiences.Sign[0], jwk) @@ -90,6 +98,13 @@ func TestCollection_LoadByToken(t *testing.T) { t4, c4, err := parseToken(token) 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 { byID *sync.Map audiences Audiences @@ -108,8 +123,10 @@ func TestCollection_LoadByToken(t *testing.T) { {"ok1", fields{byID, testAudiences}, args{t1, c1}, p1, true}, {"ok2", fields{byID, testAudiences}, args{t2, c2}, p2, 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}, {"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 { t.Run(tt.name, func(t *testing.T) { diff --git a/authority/provisioner/k8sSA.go b/authority/provisioner/k8sSA.go new file mode 100644 index 00000000..3e08a7e2 --- /dev/null +++ b/authority/provisioner/k8sSA.go @@ -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 +} +*/ diff --git a/authority/provisioner/k8sSA_test.go b/authority/provisioner/k8sSA_test.go new file mode 100644 index 00000000..31bf6d0a --- /dev/null +++ b/authority/provisioner/k8sSA_test.go @@ -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) + } + }) + } +} diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 155e34de..1be21854 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -88,6 +88,8 @@ const ( TypeACME Type = 6 // TypeX5C is used to indicate the X5C provisioners. 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 = "revoke" @@ -112,6 +114,8 @@ func (t Type) String() string { return "ACME" case TypeX5C: return "X5C" + case TypeK8sSA: + return "K8sSA" default: return "" } @@ -163,6 +167,8 @@ func (l *List) UnmarshalJSON(data []byte) error { p = &ACME{} case "x5c": p = &X5C{} + case "k8ssa": + p = &K8sSA{} default: // Skip unsupported provisioners. A client using this method may be // compiled with a version of smallstep/certificates that does not diff --git a/authority/provisioner/testdata/bar.priv b/authority/provisioner/testdata/bar.priv new file mode 100644 index 00000000..c9bf8d51 --- /dev/null +++ b/authority/provisioner/testdata/bar.priv @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIM8wGIzCKjAOGdBFmYHtS791Ly2I9FtmknEsR2sa63s7oAoGCCqGSM49 +AwEHoUQDQgAEGQIXbsr73X28pzwC1wa+ccY2H3s8PplbkapCrwxyYVvM78y/GmeS +A7fv2MKQ8iKpCw461MlOQGX+VlWT+ChRFw== +-----END EC PRIVATE KEY----- diff --git a/authority/provisioner/testdata/bar.pub b/authority/provisioner/testdata/bar.pub new file mode 100644 index 00000000..66982101 --- /dev/null +++ b/authority/provisioner/testdata/bar.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGQIXbsr73X28pzwC1wa+ccY2H3s8 +PplbkapCrwxyYVvM78y/GmeSA7fv2MKQ8iKpCw461MlOQGX+VlWT+ChRFw== +-----END PUBLIC KEY----- diff --git a/authority/provisioner/testdata/foo.priv b/authority/provisioner/testdata/foo.priv new file mode 100644 index 00000000..f2a16687 --- /dev/null +++ b/authority/provisioner/testdata/foo.priv @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIB8ovUg0Atvz+b+XiF8QV722OivOm1geGtI3sP0F48N1oAoGCCqGSM49 +AwEHoUQDQgAEzriaeV2e1aEz33x62kyqVC6ootU7rl41L8cyeOJ4SjTu4FV+o5i4 +NsS6DCE07JJSHlqc9PsrzjSs4LZD4gWVLQ== +-----END EC PRIVATE KEY----- diff --git a/authority/provisioner/testdata/foo.pub b/authority/provisioner/testdata/foo.pub new file mode 100644 index 00000000..b5ea84ad --- /dev/null +++ b/authority/provisioner/testdata/foo.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzriaeV2e1aEz33x62kyqVC6ootU7 +rl41L8cyeOJ4SjTu4FV+o5i4NsS6DCE07JJSHlqc9PsrzjSs4LZD4gWVLQ== +-----END PUBLIC KEY----- diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 8f4fbaad..554e38ea 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -10,11 +10,13 @@ import ( "encoding/json" "encoding/pem" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "time" "github.com/pkg/errors" + "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/randutil" "github.com/smallstep/cli/jose" ) @@ -197,6 +199,43 @@ func generateJWK() (*JWK, error) { }, 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) { if root == nil { 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() } +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) { return generateSSHToken("subject@localhost", iss, aud, time.Now(), &SSHOptions{ CertType: "user",