From 5bfe96d8c748d265908320b9c7ca73c8332d9056 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 20 Jul 2023 13:03:45 -0700 Subject: [PATCH] Send X5C leaf certificate to webhooks This commit adds a new property that will be sent to authorizing and enriching webhooks when signing certificates using the X5C provisioner. --- authority/provisioner/controller.go | 4 +- authority/provisioner/controller_test.go | 44 ++++++++--- authority/provisioner/webhook.go | 17 ++++ authority/provisioner/webhook_test.go | 98 +++++++++++++++++++++++- authority/provisioner/x5c.go | 15 ++-- authority/provisioner/x5c_test.go | 5 ++ webhook/options.go | 20 +++++ webhook/options_test.go | 44 +++++++++++ webhook/types.go | 13 ++++ 9 files changed, 240 insertions(+), 20 deletions(-) diff --git a/authority/provisioner/controller.go b/authority/provisioner/controller.go index 25030fbc..6d92961a 100644 --- a/authority/provisioner/controller.go +++ b/authority/provisioner/controller.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/certificates/errs" + "github.com/smallstep/certificates/webhook" "go.step.sm/linkedca" "golang.org/x/crypto/ssh" ) @@ -77,7 +78,7 @@ func (c *Controller) AuthorizeSSHRenew(ctx context.Context, cert *ssh.Certificat return DefaultAuthorizeSSHRenew(ctx, c, cert) } -func (c *Controller) newWebhookController(templateData WebhookSetter, certType linkedca.Webhook_CertType) *WebhookController { +func (c *Controller) newWebhookController(templateData WebhookSetter, certType linkedca.Webhook_CertType, opts ...webhook.RequestBodyOption) *WebhookController { client := c.webhookClient if client == nil { client = http.DefaultClient @@ -87,6 +88,7 @@ func (c *Controller) newWebhookController(templateData WebhookSetter, certType l client: client, webhooks: c.webhooks, certType: certType, + options: opts, } } diff --git a/authority/provisioner/controller_test.go b/authority/provisioner/controller_test.go index c628f074..3bab7c4e 100644 --- a/authority/provisioner/controller_test.go +++ b/authority/provisioner/controller_test.go @@ -4,15 +4,18 @@ import ( "context" "crypto/x509" "fmt" + "net/http" "reflect" "testing" "time" + "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" "go.step.sm/linkedca" "golang.org/x/crypto/ssh" "github.com/smallstep/certificates/authority/policy" + "github.com/smallstep/certificates/webhook" ) var trueValue = true @@ -449,16 +452,39 @@ func TestDefaultAuthorizeSSHRenew(t *testing.T) { } func Test_newWebhookController(t *testing.T) { - c := &Controller{} - data := x509util.TemplateData{"foo": "bar"} - ctl := c.newWebhookController(data, linkedca.Webhook_X509) - if !reflect.DeepEqual(ctl.TemplateData, data) { - t.Error("Failed to set templateData") + cert, err := pemutil.ReadCertificate("testdata/certs/x5c-leaf.crt", pemutil.WithFirstBlock()) + if err != nil { + t.Fatal(err) } - if ctl.certType != linkedca.Webhook_X509 { - t.Error("Failed to set certType") + opts := []webhook.RequestBodyOption{webhook.WithX5CCertificate(cert)} + + type args struct { + templateData WebhookSetter + certType linkedca.Webhook_CertType + opts []webhook.RequestBodyOption } - if ctl.client == nil { - t.Error("Failed to set client") + tests := []struct { + name string + args args + want *WebhookController + }{ + {"ok", args{x509util.TemplateData{"foo": "bar"}, linkedca.Webhook_X509, nil}, &WebhookController{ + TemplateData: x509util.TemplateData{"foo": "bar"}, + certType: linkedca.Webhook_X509, + client: http.DefaultClient, + }}, + {"ok with options", args{x509util.TemplateData{"foo": "bar"}, linkedca.Webhook_SSH, opts}, &WebhookController{ + TemplateData: x509util.TemplateData{"foo": "bar"}, + certType: linkedca.Webhook_SSH, + client: http.DefaultClient, + options: opts, + }}, + } + for _, tt := range tests { + c := &Controller{} + got := c.newWebhookController(tt.args.templateData, tt.args.certType, tt.args.opts...) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("newWebhookController() = %v, want %v", got, tt.want) + } } } diff --git a/authority/provisioner/webhook.go b/authority/provisioner/webhook.go index cb15547d..407b84d8 100644 --- a/authority/provisioner/webhook.go +++ b/authority/provisioner/webhook.go @@ -30,6 +30,7 @@ type WebhookController struct { client *http.Client webhooks []*Webhook certType linkedca.Webhook_CertType + options []webhook.RequestBodyOption TemplateData WebhookSetter } @@ -39,6 +40,14 @@ func (wc *WebhookController) Enrich(req *webhook.RequestBody) error { if wc == nil { return nil } + + // Apply extra options in the webhook controller + for _, fn := range wc.options { + if err := fn(req); err != nil { + return err + } + } + for _, wh := range wc.webhooks { if wh.Kind != linkedca.Webhook_ENRICHING.String() { continue @@ -63,6 +72,14 @@ func (wc *WebhookController) Authorize(req *webhook.RequestBody) error { if wc == nil { return nil } + + // Apply extra options in the webhook controller + for _, fn := range wc.options { + if err := fn(req); err != nil { + return err + } + } + for _, wh := range wc.webhooks { if wh.Kind != linkedca.Webhook_AUTHORIZING.String() { continue diff --git a/authority/provisioner/webhook_test.go b/authority/provisioner/webhook_test.go index a7895638..656d75d8 100644 --- a/authority/provisioner/webhook_test.go +++ b/authority/provisioner/webhook_test.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "crypto/tls" + "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" @@ -16,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/assert" "github.com/smallstep/certificates/webhook" + "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" "go.step.sm/linkedca" ) @@ -96,12 +98,18 @@ func TestWebhookController_isCertTypeOK(t *testing.T) { } func TestWebhookController_Enrich(t *testing.T) { + cert, err := pemutil.ReadCertificate("testdata/certs/x5c-leaf.crt", pemutil.WithFirstBlock()) + if err != nil { + t.Fatal(err) + } + type test struct { ctl *WebhookController req *webhook.RequestBody responses []*webhook.ResponseBody expectErr bool expectTemplateData any + assertRequest func(t *testing.T, req *webhook.RequestBody) } tests := map[string]test{ "ok/no enriching webhooks": { @@ -170,6 +178,29 @@ func TestWebhookController_Enrich(t *testing.T) { }, }, }, + "ok/with options": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}}, + TemplateData: x509util.TemplateData{}, + options: []webhook.RequestBodyOption{webhook.WithX5CCertificate(cert)}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: true, Data: map[string]any{"role": "bar"}}}, + expectErr: false, + expectTemplateData: x509util.TemplateData{"Webhooks": map[string]any{"people": map[string]any{"role": "bar"}}}, + assertRequest: func(t *testing.T, req *webhook.RequestBody) { + key, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + assert.FatalError(t, err) + assert.Equals(t, &webhook.X5CCertificate{ + Raw: cert.Raw, + PublicKey: key, + PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + }, req.X5CCertificate) + }, + }, "deny": { ctl: &WebhookController{ client: http.DefaultClient, @@ -181,6 +212,20 @@ func TestWebhookController_Enrich(t *testing.T) { expectErr: true, expectTemplateData: x509util.TemplateData{}, }, + "fail/with options": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "ENRICHING"}}, + TemplateData: x509util.TemplateData{}, + options: []webhook.RequestBodyOption{webhook.WithX5CCertificate(&x509.Certificate{ + PublicKey: []byte("bad"), + })}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: false}}, + expectErr: true, + expectTemplateData: x509util.TemplateData{}, + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { @@ -200,16 +245,25 @@ func TestWebhookController_Enrich(t *testing.T) { t.Fatalf("Got err %v, want %v", err, test.expectErr) } assert.Equals(t, test.expectTemplateData, test.ctl.TemplateData) + if test.assertRequest != nil { + test.assertRequest(t, test.req) + } }) } } func TestWebhookController_Authorize(t *testing.T) { + cert, err := pemutil.ReadCertificate("testdata/certs/x5c-leaf.crt", pemutil.WithFirstBlock()) + if err != nil { + t.Fatal(err) + } + type test struct { - ctl *WebhookController - req *webhook.RequestBody - responses []*webhook.ResponseBody - expectErr bool + ctl *WebhookController + req *webhook.RequestBody + responses []*webhook.ResponseBody + expectErr bool + assertRequest func(t *testing.T, req *webhook.RequestBody) } tests := map[string]test{ "ok/no enriching webhooks": { @@ -240,6 +294,27 @@ func TestWebhookController_Authorize(t *testing.T) { responses: []*webhook.ResponseBody{{Allow: false}}, expectErr: false, }, + "ok/with options": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}}, + options: []webhook.RequestBodyOption{webhook.WithX5CCertificate(cert)}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: true}}, + expectErr: false, + assertRequest: func(t *testing.T, req *webhook.RequestBody) { + key, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + assert.FatalError(t, err) + assert.Equals(t, &webhook.X5CCertificate{ + Raw: cert.Raw, + PublicKey: key, + PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + }, req.X5CCertificate) + }, + }, "deny": { ctl: &WebhookController{ client: http.DefaultClient, @@ -249,6 +324,18 @@ func TestWebhookController_Authorize(t *testing.T) { responses: []*webhook.ResponseBody{{Allow: false}}, expectErr: true, }, + "fail/with options": { + ctl: &WebhookController{ + client: http.DefaultClient, + webhooks: []*Webhook{{Name: "people", Kind: "AUTHORIZING"}}, + options: []webhook.RequestBodyOption{webhook.WithX5CCertificate(&x509.Certificate{ + PublicKey: []byte("bad"), + })}, + }, + req: &webhook.RequestBody{}, + responses: []*webhook.ResponseBody{{Allow: false}}, + expectErr: true, + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { @@ -267,6 +354,9 @@ func TestWebhookController_Authorize(t *testing.T) { if (err != nil) != test.expectErr { t.Fatalf("Got err %v, want %v", err, test.expectErr) } + if test.assertRequest != nil { + test.assertRequest(t, test.req) + } }) } } diff --git a/authority/provisioner/x5c.go b/authority/provisioner/x5c.go index d2a7c954..f0b08826 100644 --- a/authority/provisioner/x5c.go +++ b/authority/provisioner/x5c.go @@ -15,6 +15,7 @@ import ( "go.step.sm/linkedca" "github.com/smallstep/certificates/errs" + "github.com/smallstep/certificates/webhook" ) // x5cPayload extends jwt.Claims with step attributes. @@ -215,7 +216,8 @@ func (p *X5C) AuthorizeSign(_ context.Context, token string) ([]SignOption, erro // The X509 certificate will be available using the template variable // AuthorizationCrt. For example {{ .AuthorizationCrt.DNSNames }} can be // used to get all the domains. - data.SetAuthorizationCertificate(claims.chains[0][0]) + x5cLeaf := claims.chains[0][0] + data.SetAuthorizationCertificate(x5cLeaf) templateOptions, err := TemplateOptions(p.Options, data) if err != nil { @@ -238,7 +240,7 @@ func (p *X5C) AuthorizeSign(_ context.Context, token string) ([]SignOption, erro newProvisionerExtensionOption(TypeX5C, p.Name, ""), profileLimitDuration{ p.ctl.Claimer.DefaultTLSCertDuration(), - claims.chains[0][0].NotBefore, claims.chains[0][0].NotAfter, + x5cLeaf.NotBefore, x5cLeaf.NotAfter, }, // validators commonNameValidator(claims.Subject), @@ -246,7 +248,7 @@ func (p *X5C) AuthorizeSign(_ context.Context, token string) ([]SignOption, erro defaultPublicKeyValidator{}, newValidityValidator(p.ctl.Claimer.MinTLSCertDuration(), p.ctl.Claimer.MaxTLSCertDuration()), newX509NamePolicyValidator(p.ctl.getPolicy().getX509()), - p.ctl.newWebhookController(data, linkedca.Webhook_X509), + p.ctl.newWebhookController(data, linkedca.Webhook_X509, webhook.WithX5CCertificate(x5cLeaf)), }, nil } @@ -305,7 +307,8 @@ func (p *X5C) AuthorizeSSHSign(_ context.Context, token string) ([]SignOption, e // The X509 certificate will be available using the template variable // AuthorizationCrt. For example {{ .AuthorizationCrt.DNSNames }} can be // used to get all the domains. - data.SetAuthorizationCertificate(claims.chains[0][0]) + x5cLeaf := claims.chains[0][0] + data.SetAuthorizationCertificate(x5cLeaf) templateOptions, err := TemplateSSHOptions(p.Options, data) if err != nil { @@ -325,7 +328,7 @@ func (p *X5C) AuthorizeSSHSign(_ context.Context, token string) ([]SignOption, e return append(signOptions, p, // Checks the validity bounds, and set the validity if has not been set. - &sshLimitDuration{p.ctl.Claimer, claims.chains[0][0].NotAfter}, + &sshLimitDuration{p.ctl.Claimer, x5cLeaf.NotAfter}, // Validate public key. &sshDefaultPublicKeyValidator{}, // Validate the validity period. @@ -335,6 +338,6 @@ func (p *X5C) AuthorizeSSHSign(_ context.Context, token string) ([]SignOption, e // Ensure that all principal names are allowed newSSHNamePolicyValidator(p.ctl.getPolicy().getSSHHost(), p.ctl.getPolicy().getSSHUser()), // Call webhooks - p.ctl.newWebhookController(data, linkedca.Webhook_SSH), + p.ctl.newWebhookController(data, linkedca.Webhook_SSH, webhook.WithX5CCertificate(x5cLeaf)), ), nil } diff --git a/authority/provisioner/x5c_test.go b/authority/provisioner/x5c_test.go index 72f9f947..ec3e0c73 100644 --- a/authority/provisioner/x5c_test.go +++ b/authority/provisioner/x5c_test.go @@ -12,6 +12,7 @@ import ( "go.step.sm/crypto/jose" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/randutil" + "go.step.sm/linkedca" "github.com/smallstep/assert" "github.com/smallstep/certificates/api/render" @@ -497,6 +498,8 @@ func TestX5C_AuthorizeSign(t *testing.T) { assert.Equals(t, nil, v.policyEngine) case *WebhookController: assert.Len(t, 0, v.webhooks) + assert.Equals(t, linkedca.Webhook_X509, v.certType) + assert.Len(t, 1, v.options) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } @@ -801,6 +804,8 @@ func TestX5C_AuthorizeSSHSign(t *testing.T) { case *sshDefaultPublicKeyValidator, *sshCertDefaultValidator, sshCertificateOptionsFunc: case *WebhookController: assert.Len(t, 0, v.webhooks) + assert.Equals(t, linkedca.Webhook_SSH, v.certType) + assert.Len(t, 1, v.options) default: assert.FatalError(t, fmt.Errorf("unexpected sign option of type %T", v)) } diff --git a/webhook/options.go b/webhook/options.go index 88c44986..0e82e68c 100644 --- a/webhook/options.go +++ b/webhook/options.go @@ -95,3 +95,23 @@ func WithSSHCertificate(cert *sshutil.Certificate, certTpl *ssh.Certificate) Req return nil } } + +func WithX5CCertificate(leaf *x509.Certificate) RequestBodyOption { + return func(rb *RequestBody) error { + rb.X5CCertificate = &X5CCertificate{ + Raw: leaf.Raw, + PublicKeyAlgorithm: leaf.PublicKeyAlgorithm.String(), + NotBefore: leaf.NotBefore, + NotAfter: leaf.NotAfter, + } + if leaf.PublicKey != nil { + key, err := x509.MarshalPKIXPublicKey(leaf.PublicKey) + if err != nil { + return err + } + rb.X5CCertificate.PublicKey = key + } + + return nil + } +} diff --git a/webhook/options_test.go b/webhook/options_test.go index e813bb44..9bcc59bc 100644 --- a/webhook/options_test.go +++ b/webhook/options_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/smallstep/assert" + "go.step.sm/crypto/keyutil" "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" "golang.org/x/crypto/ssh" @@ -16,6 +17,15 @@ func TestNewRequestBody(t *testing.T) { t1 := time.Now() t2 := t1.Add(time.Hour) + key, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatal(err) + } + keyBytes, err := x509.MarshalPKIXPublicKey(key.Public()) + if err != nil { + t.Fatal(err) + } + type test struct { options []RequestBodyOption want *RequestBody @@ -103,6 +113,40 @@ func TestNewRequestBody(t *testing.T) { }, wantErr: false, }, + "X5C Certificate": { + options: []RequestBodyOption{ + WithX5CCertificate(&x509.Certificate{ + Raw: []byte("some raw data"), + NotBefore: t1, + NotAfter: t2, + PublicKeyAlgorithm: x509.ECDSA, + PublicKey: key.Public(), + }), + }, + want: &RequestBody{ + X5CCertificate: &X5CCertificate{ + Raw: []byte("some raw data"), + PublicKeyAlgorithm: "ECDSA", + NotBefore: t1, + NotAfter: t2, + PublicKey: keyBytes, + }, + }, + wantErr: false, + }, + "fail/X5C Certificate": { + options: []RequestBodyOption{ + WithX5CCertificate(&x509.Certificate{ + Raw: []byte("some raw data"), + NotBefore: t1, + NotAfter: t2, + PublicKeyAlgorithm: x509.ECDSA, + PublicKey: []byte("fail"), + }), + }, + want: nil, + wantErr: true, + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { diff --git a/webhook/types.go b/webhook/types.go index 9605742a..02f36b56 100644 --- a/webhook/types.go +++ b/webhook/types.go @@ -56,6 +56,17 @@ type AttestationData struct { PermanentIdentifier string `json:"permanentIdentifier"` } +// X5CCertificate is the authorization certificate sent to webhook servers for +// enriching or authorizing webhooks when signing X509 or SSH certificates using +// the X5C provisioner. +type X5CCertificate struct { + Raw []byte `json:"raw"` + PublicKey []byte `json:"publicKey"` + PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` + NotBefore time.Time `json:"notBefore"` + NotAfter time.Time `json:"notAfter"` +} + // RequestBody is the body sent to webhook servers. type RequestBody struct { Timestamp time.Time `json:"timestamp"` @@ -71,4 +82,6 @@ type RequestBody struct { // Only set for SCEP challenge validation requests SCEPChallenge string `json:"scepChallenge,omitempty"` SCEPTransactionID string `json:"scepTransactionID,omitempty"` + // Only set for X5C provisioners + X5CCertificate *X5CCertificate `json:"x5cCertificate,omitempty"` }