diff --git a/.gitignore b/.gitignore index c17ed53a2..313f51d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ go.work.sum # Output of the go coverage tool, specifically when used with LiteIDE *.out +.gocache # Others *.swp diff --git a/api/api.go b/api/api.go index 09d2c83fb..9de47e890 100644 --- a/api/api.go +++ b/api/api.go @@ -257,22 +257,32 @@ func scepFromProvisioner(p *provisioner.SCEP) *models.SCEP { } } +func estFromProvisioner(p *provisioner.EST) *provisioner.EST { + prov := *p + // prov.ClientCertificateRoots = []byte(redacted) + // prov.BasicAuthUsername = redacted + // prov.BasicAuthPassword = redacted + return &prov +} + // MarshalJSON implements json.Marshaler. It marshals the ProvisionersResponse // into a byte slice. // // Special treatment is given to the SCEP provisioner, as it contains a // challenge secret that MUST NOT be leaked in (public) HTTP responses. The -// challenge value is thus redacted in HTTP responses. +// challenge value is thus redacted in HTTP responses. EST provisioners also +// contain a shared secret and are redacted in responses. func (p ProvisionersResponse) MarshalJSON() ([]byte, error) { var responseProvisioners provisioner.List for _, item := range p.Provisioners { - scepProv, ok := item.(*provisioner.SCEP) - if !ok { + switch prov := item.(type) { + case *provisioner.SCEP: + responseProvisioners = append(responseProvisioners, scepFromProvisioner(prov)) + case *provisioner.EST: + responseProvisioners = append(responseProvisioners, estFromProvisioner(prov)) + default: responseProvisioners = append(responseProvisioners, item) - continue } - - responseProvisioners = append(responseProvisioners, scepFromProvisioner(scepProv)) } var list = struct { diff --git a/authority/admin/db.go b/authority/admin/db.go index 63940a8a3..3a5961c07 100644 --- a/authority/admin/db.go +++ b/authority/admin/db.go @@ -46,6 +46,8 @@ func UnmarshalProvisionerDetails(typ linkedca.Provisioner_Type, data []byte) (*l v.Data = new(linkedca.ProvisionerDetails_SCEP) case linkedca.Provisioner_NEBULA: v.Data = new(linkedca.ProvisionerDetails_Nebula) + case linkedca.Provisioner_EST: + v.Data = new(linkedca.ProvisionerDetails_EST) default: return nil, fmt.Errorf("unsupported provisioner type %s", typ) } diff --git a/authority/authority.go b/authority/authority.go index 8dd6a0b8e..a83eee816 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -34,6 +34,7 @@ import ( "github.com/smallstep/certificates/cas" casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/est" "github.com/smallstep/certificates/internal/httptransport" "github.com/smallstep/certificates/scep" "github.com/smallstep/certificates/templates" @@ -72,6 +73,10 @@ type Authority struct { scepAuthority *scep.Authority scepKeyManager provisioner.SCEPKeyManager + // EST CA + estOptions *est.Options + estAuthority *est.Authority + // SSH CA sshHostPassword []byte sshUserPassword []byte @@ -816,6 +821,28 @@ func (a *Authority) init() error { } } + // EST functionality is provided through an instance of est.Authority. + switch { + case a.requiresEST() && a.GetEST() == nil: + if a.estOptions == nil { + a.estOptions = &est.Options{ + Roots: a.rootX509Certs, + Intermediates: a.intermediateX509Certs, + } + } + + estAuthority, err := est.New(a, *a.estOptions) + if err != nil { + return err + } + + a.estAuthority = estAuthority + case !a.requiresEST() && a.GetEST() != nil: + a.estAuthority = nil + case a.requiresEST() && a.GetEST() != nil: + // no-op + } + // Load X509 constraints engine. // // This is currently only available in CA mode. @@ -1004,7 +1031,19 @@ func (a *Authority) GetSCEP() *scep.Authority { return a.scepAuthority } -// HasACMEProvisioner returns true if at least one ACME provisioner is configured. +// requiresEST iterates over the configured provisioners +// and determines if at least one of them is an EST provisioner. +func (a *Authority) requiresEST() bool { + for _, p := range a.config.AuthorityConfig.Provisioners { + if p.GetType() == provisioner.TypeEST { + return true + } + } + return false +} + +// HasACMEProvisioner iterates over the configured provisioners +// and determines if at least one of them is an ACME provisioner. func (a *Authority) HasACMEProvisioner() bool { for _, p := range a.config.AuthorityConfig.Provisioners { if p.GetType() == provisioner.TypeACME { @@ -1014,6 +1053,23 @@ func (a *Authority) HasACMEProvisioner() bool { return false } +// getESTProvisionerNames returns the names of the EST provisioners +// that are currently available in the CA. +func (a *Authority) getESTProvisionerNames() (names []string) { + for _, p := range a.config.AuthorityConfig.Provisioners { + if p.GetType() == provisioner.TypeEST { + names = append(names, p.GetName()) + } + } + + return +} + +// GetEST returns the configured EST Authority +func (a *Authority) GetEST() *est.Authority { + return a.estAuthority +} + func (a *Authority) startCRLGenerator() error { if !a.config.CRL.IsEnabled() { return nil diff --git a/authority/options.go b/authority/options.go index b12f83887..a1f1a8209 100644 --- a/authority/options.go +++ b/authority/options.go @@ -18,6 +18,7 @@ import ( "github.com/smallstep/certificates/cas" casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/est" "github.com/smallstep/certificates/internal/httptransport" "github.com/smallstep/certificates/scep" ) @@ -252,6 +253,16 @@ func WithFullSCEPOptions(options *scep.Options) Option { } } +// WithFullESTOptions defines the options used for EST support. +// +// This feature is EXPERIMENTAL and might change at any time. +func WithFullESTOptions(options *est.Options) Option { + return func(a *Authority) error { + a.estOptions = options + return nil + } +} + // WithSCEPKeyManager defines the key manager used on SCEP provisioners. // // This feature is EXPERIMENTAL and might change at any time. diff --git a/authority/provisioner/est.go b/authority/provisioner/est.go new file mode 100644 index 000000000..fcad7bbda --- /dev/null +++ b/authority/provisioner/est.go @@ -0,0 +1,176 @@ +package provisioner + +import ( + "context" + "crypto" + "crypto/x509" + "time" + + "github.com/pkg/errors" + + "github.com/smallstep/linkedca" + + "github.com/smallstep/certificates/authority/provisioner/est" + + "github.com/smallstep/certificates/internal/httptransport" +) + +// EST is the EST provisioner type, an entity that can authorize the EST flow. +type EST struct { + *base + ID string `json:"-"` + Type string `json:"type"` + Name string `json:"name"` + + ForceCN bool `json:"forceCN,omitempty"` + MinimumPublicKeyLength int `json:"minimumPublicKeyLength,omitempty"` + + // Authentication configures accepted client authentication. At + // least one method is required; RFC 7030 3.2.3 permits requiring + // HTTP authentication in addition to TLS client authentication. + Authentication est.Authentication `json:"authentication"` + + // Operations enables EST operations. Defaults to cacerts, + // csrattrs, simpleenroll and simplereenroll. + Operations []est.Operation `json:"operations,omitempty"` + + CACerts est.CACerts `json:"cacerts,omitempty"` + CSRAttributes *est.CSRAttributes `json:"csrAttributes,omitempty"` + + // ProofOfPossession controls tls-unique identity/PoP linking + // (RFC 7030 3.5): disabled | optional | required. + ProofOfPossession est.PoPMode `json:"proofOfPossession,omitempty"` + + DummyBool *bool + DummyString string + + // EnableTLSClientCertificate *bool `json:"enableTlsClientCertificate,omitempty"` + // ForwardedTLSClientCertHeader string `json:"forwardedTlsClientCertHeader,omitempty"` + // EnableHTTPBasicAuth *bool `json:"enableHTTPBasicAuth,omitempty"` + // BasicAuthUsername string `json:"basicAuthUsername,omitempty"` + // BasicAuthPassword string `json:"basicAuthPassword,omitempty"` + // ClientCertificateRoots []byte `json:"clientCertificateRoots,omitempty"` + + Options *Options `json:"options,omitempty"` + Claims *Claims `json:"claims,omitempty"` + + ctl *Controller + signer crypto.Signer + signerCertificate *x509.Certificate + challengeValidationController *challengeValidationController + clientCertificateRootPool *x509.CertPool +} + +// GetID returns the provisioner unique identifier. +func (s *EST) GetID() string { + if s.ID != "" { + return s.ID + } + return s.GetIDForToken() +} + +// GetIDForToken returns an identifier that will be used to load the provisioner from a token. +func (s *EST) GetIDForToken() string { + return "est/" + s.Name +} + +// GetName returns the name of the provisioner. +func (s *EST) GetName() string { + return s.Name +} + +// GetType returns the type of provisioner. +func (s *EST) GetType() Type { + return TypeEST +} + +// GetEncryptedKey returns the base provisioner encrypted key if it's defined. +func (s *EST) GetEncryptedKey() (string, string, bool) { + return "", "", false +} + +// GetTokenID returns the identifier of the token. This provisioner does not support tokens. +func (s *EST) GetTokenID(string) (string, error) { + return "", ErrTokenFlowNotSupported +} + +// GetOptions returns the configured provisioner options. +func (s *EST) GetOptions() *Options { + return s.Options +} + +// DefaultTLSCertDuration returns the default TLS cert duration enforced by the provisioner. +func (s *EST) DefaultTLSCertDuration() time.Duration { + return s.ctl.Claimer.DefaultTLSCertDuration() +} + +// newChallengeValidationController creates a new challengeValidationController +// that performs challenge validation through webhooks. +func newESTChallengeValidationController(client HTTPClient, tw httptransport.Wrapper, webhooks []*Webhook) *challengeValidationController { + estHooks := []*Webhook{} + for _, wh := range webhooks { + if wh.Kind != linkedca.Webhook_ESTCHALLENGE.String() { + continue + } + estHooks = append(estHooks, wh) + } + return &challengeValidationController{ + client: client, + wrapTransport: tw, + webhooks: estHooks, + } +} + +// Init initializes and validates the fields of an EST type. +func (s *EST) Init(config Config) (err error) { + switch { + case s.Type == "": + return errors.New("provisioner type cannot be empty") + case s.Name == "": + return errors.New("provisioner name cannot be empty") + } + + if s.MinimumPublicKeyLength == 0 { + s.MinimumPublicKeyLength = 2048 + } + if s.MinimumPublicKeyLength%8 != 0 { + return errors.Errorf("%d bits is not exactly divisible by 8", s.MinimumPublicKeyLength) + } + + // Prepare the EST challenge validator + s.challengeValidationController = newESTChallengeValidationController( + config.WebhookClient, + config.WrapTransport, + s.GetOptions().GetWebhooks(), + ) + + // if err := s.parseClientCertificateRoots(); err != nil { + // return err + // } + + // if err := s.normalizeAuthConfig(); err != nil { + // return err + // } + + s.ctl, err = NewController(s, s.Claims, config, s.Options) + return err +} + +// AuthorizeSign does not do any verification; main validation is in the EST protocol. +func (s *EST) AuthorizeSign(context.Context, string) ([]SignOption, error) { + return []SignOption{ + s, + newProvisionerExtensionOption(TypeEST, s.Name, "").WithControllerOptions(s.ctl), + newForceCNOption(s.ForceCN), + profileDefaultDuration(s.ctl.Claimer.DefaultTLSCertDuration()), + newPublicKeyMinimumLengthValidator(s.MinimumPublicKeyLength), + newValidityValidator(s.ctl.Claimer.MinTLSCertDuration(), s.ctl.Claimer.MaxTLSCertDuration()), + newX509NamePolicyValidator(s.ctl.getPolicy().getX509()), + s.ctl.newWebhookController(nil, linkedca.Webhook_X509), + }, nil +} + +// GetCSRAttributes returns the CSR attributes to signal to clients. +func (s *EST) GetCSRAttributes(context.Context) ([]byte, error) { + return nil, nil // TODO(hs): refactor +} diff --git a/authority/provisioner/est/est.go b/authority/provisioner/est/est.go new file mode 100644 index 000000000..31822282e --- /dev/null +++ b/authority/provisioner/est/est.go @@ -0,0 +1,92 @@ +package est + +import ( + "crypto/x509" + "errors" +) + +var ( + ErrAuthMethodDisabled = errors.New("est authentication method disabled") + ErrAuthMethodNotFound = errors.New("no valid est authentication method found") + ErrAuthMethodMisconfigured = errors.New("est authentication method misconfigured") + ErrAuthDenied = errors.New("est authentication denied") +) + +type AuthMode string + +type HTTPAuth struct{} + +type EnrollmentIdentity string + +type Operation string + +type CSRAttributes struct{} + +type PoPMode string + +type Authentication struct { + // Mode is "any" (default) or "all", requiring every enabled + // method to succeed. + Mode AuthMode `json:"mode,omitempty"` + + // ClientCertificate authenticates via the TLS client certificate + // (RFC 7030 3.3.2). + ClientCertificate *ClientCertificateAuth `json:"clientCertificate,omitempty"` + + // HTTP authenticates via HTTP Basic or Digest (RFC 7030 3.2.3). + HTTP *HTTPAuth `json:"http,omitempty"` +} + +type ClientCertificateAuth struct { + // Roots is a PEM bundle of external trust anchors for initial + // enrollment with an existing certificate (RFC 7030 2.2.1). + Roots []byte `json:"roots,omitempty"` + + // AllowOwnCertificates accepts certificates issued by this CA, + // required for re-enrollment (RFC 7030 4.2.2). Scope with + // Provisioners; otherwise every leaf this CA has issued becomes + // an EST enrollment credential. + AllowOwnCertificates bool `json:"allowOwnCertificates,omitempty"` + Provisioners []string `json:"provisioners,omitempty"` + + // Forwarded reads the client certificate from a header set by a + // TLS-terminating proxy. Incompatible with ProofOfPossession. + Forwarded *ForwardedClientCertificate `json:"forwarded,omitempty"` + + // EnrollmentIdentity is "unrestricted" (default) or "match", + // requiring the CSR subject and SANs to equal the authenticating + // certificate's. Always enforced for simplereenroll (4.2.2). + EnrollmentIdentity EnrollmentIdentity `json:"enrollmentIdentity,omitempty"` +} + +type ForwardedClientCertificate struct { + Header string `json:"header"` + Format string `json:"format,omitempty"` // pem | der | url-encoded-pem | xfcc + TrustedProxies []string `json:"trustedProxies,omitempty"` // CIDRs +} + +type CACerts struct { + ExcludeRoot bool `json:"excludeRoot,omitempty"` + ExcludeIntermediate bool `json:"excludeIntermediate,omitempty"` + // Additional is a PEM bundle appended to /cacerts, e.g. Root CA + // Key Update certificates (RFC 7030 4.1.3). + Additional []byte `json:"additional,omitempty"` +} + +// AuthRequest contains authentication material extracted from the request. +type AuthRequest struct { + CSR *x509.CertificateRequest + ClientCertificate *x509.Certificate + ClientCertificateChain []*x509.Certificate + CARoots []*x509.Certificate + CAIntermediates []*x509.Certificate + AuthenticationHeader string + BasicAuthUsername string + BasicAuthPassword string + BearerToken string +} + +// HasBasicAuth reports whether any basic auth data is present. +func (r *AuthRequest) HasBasicAuth() bool { + return r.BasicAuthUsername != "" || r.BasicAuthPassword != "" +} diff --git a/authority/provisioner/est_auth.go b/authority/provisioner/est_auth.go new file mode 100644 index 000000000..2917efcbc --- /dev/null +++ b/authority/provisioner/est_auth.go @@ -0,0 +1,249 @@ +package provisioner + +import ( + "context" + "crypto/subtle" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + + "go.step.sm/crypto/x509util" + + "github.com/smallstep/certificates/authority/provisioner/est" + "github.com/smallstep/certificates/webhook" +) + +// ClientCertificateConfig holds the EST client certificate authentication configuration. +type ClientCertificateConfig struct { + Enable bool + ForwardedTLSClientCertHeader string +} + +func (s *EST) GetClientCertificateConfig() *ClientCertificateConfig { + return &ClientCertificateConfig{ + Enable: boolValue(s.DummyBool, false), + ForwardedTLSClientCertHeader: s.DummyString, + } +} + +// AuthorizeRequest validates the request against configured EST auth methods. +func (s *EST) AuthorizeRequest(ctx context.Context, req est.AuthRequest) ([]SignCSROption, error) { + if s.hasAuthWebhooks() { + return s.authorizeWithWebhook(ctx, &req) + } + return s.authorizeRequestLocal(req) +} + +// authorizeRequestLocal validates the request using provisioner configuration. +func (s *EST) authorizeRequestLocal(req est.AuthRequest) ([]SignCSROption, error) { + var lastErr error = est.ErrAuthMethodNotFound + if req.ClientCertificate != nil { + if boolValue(s.DummyBool, false) { // TODO(hs): refactor + if s.hasClientCertificateRoots() { + if err := verifyCertificateWithPool(req.ClientCertificate, req.ClientCertificateChain, s.clientCertificateRootPool, nil); err == nil { + return []SignCSROption{}, nil + } else { + lastErr = err + } + } else { + if err := verifyCertificate(req.ClientCertificate, req.ClientCertificateChain, req.CARoots, req.CAIntermediates); err == nil { + return []SignCSROption{}, nil + } else { + lastErr = err + } + } + } else { + lastErr = est.ErrAuthMethodDisabled + } + } + + if req.HasBasicAuth() { + if boolValue(s.DummyBool, false) && s.DummyString != "" { + if err := s.validateBasicAuthPassword(req.BasicAuthUsername, req.BasicAuthPassword); err == nil { + return []SignCSROption{}, nil + } else { + lastErr = err + } + } else { + lastErr = est.ErrAuthMethodDisabled + } + } + + return nil, lastErr +} + +// validateBasicAuthPassword verifies the configured basic auth password. +func (s *EST) validateBasicAuthPassword(username, password string) error { + if s.DummyString != "" && username != s.DummyString { + return errors.New("invalid basic auth") + } + if subtleCompare(s.DummyString, password) { + return nil + } + return errors.New("invalid basic auth") +} + +// authorizeWithWebhook executes configured webhooks for auth decisions. +func (s *EST) authorizeWithWebhook(ctx context.Context, req *est.AuthRequest) ([]SignCSROption, error) { + if !s.hasAuthWebhooks() { + return nil, est.ErrAuthMethodMisconfigured + } + + var ( + whreq *webhook.RequestBody + err error + ) + switch { + case req.ClientCertificate != nil: + whreq, err = webhook.NewRequestBody(webhook.WithX509CertificateRequest(req.CSR), webhook.WithClientCertificate(req.ClientCertificate)) + if err != nil { + return nil, fmt.Errorf("failed creating webhook request: %w", err) + } + case req.AuthenticationHeader != "": + whreq, err = webhook.NewRequestBody(webhook.WithX509CertificateRequest(req.CSR), webhook.WithAuthenticationHeader(req.AuthenticationHeader)) + if err != nil { + return nil, fmt.Errorf("failed creating webhook request: %w", err) + } + if req.BearerToken != "" { + whreq.BearerToken = req.BearerToken + } + default: + return nil, errors.New("missing certificate or basic auth for webhook validation") + } + whreq.ProvisionerName = s.Name + var opts []SignCSROption + + for _, wh := range s.challengeValidationController.webhooks { + resp, err := wh.DoWithContext(ctx, s.challengeValidationController.client, s.challengeValidationController.wrapTransport, whreq, nil) + if err != nil { + return nil, fmt.Errorf("failed executing webhook request: %w", err) + } + if resp.Allow { + opts = append(opts, TemplateDataModifierFunc(func(data x509util.TemplateData) { + data.SetWebhook(wh.Name, resp.Data) + })) + } + } + + if len(opts) == 0 { + return nil, est.ErrAuthDenied + } + + return opts, nil +} + +// hasAuthWebhooks reports whether auth webhooks are configured. +func (s *EST) hasAuthWebhooks() bool { + return s.challengeValidationController != nil && len(s.challengeValidationController.webhooks) > 0 +} + +// normalizeAuthConfig applies defaults and validates auth configuration. +func (s *EST) normalizeAuthConfig() error { + enable := true + if !s.authMethodsConfigured() { + s.DummyBool = &enable + } + if s.DummyBool == nil && (s.DummyString != "" || s.DummyString != "") { // TODO(hs): refactor + s.DummyBool = &enable + } + if boolValue(s.DummyBool, false) && s.DummyString == "" && !s.hasAuthWebhooks() { + return errors.New("basic auth password cannot be empty") + } + return nil +} + +// authMethodsConfigured reports whether any auth method is explicitly configured. +func (s *EST) authMethodsConfigured() bool { + return s.DummyBool != nil || + s.hasClientCertificateRoots() || + s.DummyBool != nil +} + +// parseClientCertificateRoots loads external client certificate roots. +func (s *EST) parseClientCertificateRoots() error { + if len(s.DummyString) == 0 { + return nil + } + var ( + block *pem.Block + hasCert bool + rest []byte + ) + s.clientCertificateRootPool = x509.NewCertPool() + for rest != nil { + block, rest = pem.Decode(rest) + if block == nil { + break + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return errors.New("error parsing clientCertificateRoots: malformed certificate") + } + s.clientCertificateRootPool.AddCert(cert) + hasCert = true + } + if !hasCert { + return errors.New("error parsing clientCertificateRoots: no certificates found") + } + return nil +} + +func (s *EST) hasClientCertificateRoots() bool { + return len(s.DummyString) > 0 +} + +// verifyCertificate validates the client certificate against CA roots. +func verifyCertificate(cert *x509.Certificate, chain, roots, intermediates []*x509.Certificate) error { + rootPool := x509.NewCertPool() + for _, root := range roots { + if root != nil { + rootPool.AddCert(root) + } + } + intermediatePool := x509.NewCertPool() + for _, intermediate := range intermediates { + if intermediate != nil { + intermediatePool.AddCert(intermediate) + } + } + return verifyCertificateWithPool(cert, chain, rootPool, intermediatePool) +} + +// verifyCertificateWithPool validates the client certificate using explicit pools. +func verifyCertificateWithPool(cert *x509.Certificate, chain []*x509.Certificate, roots, intermediates *x509.CertPool) error { + if intermediates == nil { + intermediates = x509.NewCertPool() + } + for i, intermediate := range chain { + if i == 0 || intermediate == nil { + continue + } + intermediates.AddCert(intermediate) + } + _, err := cert.Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }) + if err != nil { + return fmt.Errorf("invalid client certificate: %w", err) + } + return nil +} + +// boolValue returns the dereferenced value or a default. +func boolValue(value *bool, defaultValue bool) bool { + if value == nil { + return defaultValue + } + return *value +} + +// subtleCompare compares secrets in constant time. +func subtleCompare(expected, actual string) bool { + if len(expected) != len(actual) { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 +} diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index ae6e0f978..e71222d0f 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -209,6 +209,8 @@ const ( TypeSCEP Type = 10 // TypeNebula is used to indicate the Nebula provisioners TypeNebula Type = 11 + // TypeEST is used to indicate the EST provisioners + TypeEST Type = 12 ) // String returns the string representation of the type. @@ -236,6 +238,8 @@ func (t Type) String() string { return "SCEP" case TypeNebula: return "Nebula" + case TypeEST: + return "EST" default: return "" } @@ -333,6 +337,8 @@ func (l *List) UnmarshalJSON(data []byte) error { p = &SCEP{} case "nebula": p = &Nebula{} + case "est": + p = &EST{} 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/provisioners.go b/authority/provisioners.go index 3413ab309..458f8429a 100644 --- a/authority/provisioners.go +++ b/authority/provisioners.go @@ -1004,6 +1004,25 @@ func ProvisionerToCertificates(p *linkedca.Provisioner) (provisioner.Interface, s.DecrypterKeyPassword = string(decrypter.KeyPassword) } return s, nil + case *linkedca.ProvisionerDetails_EST: + cfg := d.EST + // enableTLSClientCertificate := cfg.EnableTlsClientCertificate + // enableHTTPBasicAuth := cfg.EnableHttpBasicAuth + return &provisioner.EST{ + ID: p.Id, + Type: p.Type.String(), + Name: p.Name, + ForceCN: cfg.ForceCn, + MinimumPublicKeyLength: int(cfg.MinimumPublicKeyLength), + // EnableTLSClientCertificate: &enableTLSClientCertificate, + // EnableHTTPBasicAuth: &enableHTTPBasicAuth, + // ForwardedTLSClientCertHeader: cfg.ForwardedTlsClientCertHeader, + // BasicAuthUsername: cfg.BasicAuthUsername, + // BasicAuthPassword: cfg.BasicAuthPassword, + // ClientCertificateRoots: provisionerPEMToCertificates(cfg.ClientCertificateRoots), + Claims: claims, + Options: options, + }, nil case *linkedca.ProvisionerDetails_Nebula: var roots []byte for i, root := range d.Nebula.GetRoots() { @@ -1279,6 +1298,34 @@ func ProvisionerToLinkedca(p provisioner.Interface) (*linkedca.Provisioner, erro SshTemplate: sshTemplate, Webhooks: webhooks, }, nil + case *provisioner.EST: + x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) + if err != nil { + return nil, err + } + return &linkedca.Provisioner{ + Id: p.ID, + Type: linkedca.Provisioner_EST, + Name: p.GetName(), + Details: &linkedca.ProvisionerDetails{ + Data: &linkedca.ProvisionerDetails_EST{ + EST: &linkedca.ESTProvisioner{ + ForceCn: p.ForceCN, + MinimumPublicKeyLength: cast.Int32(p.MinimumPublicKeyLength), + // EnableTlsClientCertificate: p.EnableTLSClientCertificate != nil && *p.EnableTLSClientCertificate, + // EnableHttpBasicAuth: p.EnableHTTPBasicAuth != nil && *p.EnableHTTPBasicAuth, + // BasicAuthUsername: p.BasicAuthUsername, + // BasicAuthPassword: p.BasicAuthPassword, + // ClientCertificateRoots: provisionerPEMToLinkedca(p.ClientCertificateRoots), + // ForwardedTlsClientCertHeader: p.ForwardedTLSClientCertHeader, + }, + }, + }, + Claims: claimsToLinkedca(p.Claims), + X509Template: x509Template, + SshTemplate: sshTemplate, + Webhooks: webhooks, + }, nil case *provisioner.Nebula: x509Template, sshTemplate, webhooks, err := provisionerOptionsToLinkedca(p.Options) if err != nil { diff --git a/ca/bootstrap_test.go b/ca/bootstrap_test.go index da37eee58..056a44968 100644 --- a/ca/bootstrap_test.go +++ b/ca/bootstrap_test.go @@ -54,7 +54,7 @@ func startCABootstrapServer() *httptest.Server { if err != nil { panic(err) } - baseContext := buildContext(ca.auth, nil, nil, nil) + baseContext := buildContext(ca.auth, nil, nil, nil, nil) srv.Config.Handler = ca.srv.Handler srv.Config.BaseContext = func(net.Listener) context.Context { return baseContext diff --git a/ca/ca.go b/ca/ca.go index 3f0704a0a..9f2eb07d2 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -34,6 +34,8 @@ import ( "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" + "github.com/smallstep/certificates/est" + estAPI "github.com/smallstep/certificates/est/api" "github.com/smallstep/certificates/internal/httptransport" "github.com/smallstep/certificates/internal/metrix" "github.com/smallstep/certificates/logging" @@ -324,6 +326,21 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { }) } + // EST endpoints (HTTPS only) + var estAuthority *est.Authority + if estAuth := auth.GetEST(); estAuth != nil { + // EST is served on /.well-known//est; we use the provisioner + // name as label. + // TODO(hs): decide whether we want to go with this, or also want to support + // some form of default EST provisioner that can be requested at the path without + // a label set. + estPrefix := "/.well-known/{label}/est" + estAuthority = estAuth + mux.Route(estPrefix, func(r chi.Router) { + estAPI.Route(r) + }) + } + // helpful routine for logging all routes //dumpRoutes(mux) //dumpRoutes(insecureMux) @@ -355,7 +372,7 @@ func (ca *CA) Init(cfg *config.Config) (*CA, error) { insecureHandler = requestid.New(legacyTraceHeader).Middleware(insecureHandler) // Create context with all the necessary values. - baseContext := buildContext(auth, scepAuthority, acmeDB, acmeLinker) + baseContext := buildContext(auth, scepAuthority, estAuthority, acmeDB, acmeLinker) ca.srv = server.New(cfg.Address, handler, tlsConfig) ca.srv.BaseContext = func(net.Listener) context.Context { @@ -403,7 +420,7 @@ func (ca *CA) shouldServeInsecureServer() bool { } // buildContext builds the server base context. -func buildContext(a *authority.Authority, scepAuthority *scep.Authority, acmeDB acme.DB, acmeLinker acme.Linker) context.Context { +func buildContext(a *authority.Authority, scepAuthority *scep.Authority, estAuthority *est.Authority, acmeDB acme.DB, acmeLinker acme.Linker) context.Context { ctx := authority.NewContext(context.Background(), a) if authDB := a.GetDatabase(); authDB != nil { ctx = db.NewContext(ctx, authDB) @@ -414,6 +431,9 @@ func buildContext(a *authority.Authority, scepAuthority *scep.Authority, acmeDB if scepAuthority != nil { ctx = scep.NewContext(ctx, scepAuthority) } + if estAuthority != nil { + ctx = est.NewContext(ctx, estAuthority) + } if acmeDB != nil { ctx = acme.NewContext(ctx, acmeDB, acme.NewClient(), acmeLinker, nil) } diff --git a/ca/tls_test.go b/ca/tls_test.go index 465f1ede2..885b895ae 100644 --- a/ca/tls_test.go +++ b/ca/tls_test.go @@ -78,7 +78,7 @@ func startCATestServer(t *testing.T) *httptest.Server { ca, err := New(config) require.NoError(t, err) // Use a httptest.Server instead - baseContext := buildContext(ca.auth, nil, nil, nil) + baseContext := buildContext(ca.auth, nil, nil, nil, nil) srv := startTestServer(baseContext, ca.srv.TLSConfig, ca.srv.Handler) return srv } diff --git a/est/api/api.go b/est/api/api.go new file mode 100644 index 000000000..8dc516bfd --- /dev/null +++ b/est/api/api.go @@ -0,0 +1,343 @@ +// Package api implements an EST HTTP server. +package api + +import ( + "context" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/api/log" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/provisioner" + provest "github.com/smallstep/certificates/authority/provisioner/est" + "github.com/smallstep/certificates/est" +) + +const ( + maxPayloadSize = 2 << 20 +) + +// BearerToken extracts a bearer token from an [*http.Request]. +func BearerToken(r *http.Request) (string, bool) { + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + // Case insensitive prefix match. See Issue 22736. + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return "", false + } + return auth[len(prefix):], true +} + +// Route configures the EST routes under the provided [api.Router]. +func Route(r api.Router) { + r.MethodFunc(http.MethodGet, "/cacerts", getCACerts) + r.MethodFunc(http.MethodGet, "/csrattrs", getCSRAttrs) + r.MethodFunc(http.MethodPost, "/simpleenroll", enroll) + r.MethodFunc(http.MethodPost, "/simplereenroll", enroll) +} + +func lookupProvisioner(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "label") + if name == "" { + failWithStatus(w, r, http.StatusBadRequest, errors.New("missing provisioner name")) + return + } + provisionerName, err := url.PathUnescape(name) + if err != nil { + failWithStatus(w, r, http.StatusBadRequest, fmt.Errorf("error url unescaping provisioner name '%s'", name)) + return + } + + ctx := r.Context() + auth := authority.MustFromContext(ctx) + p, err := auth.LoadProvisionerByName(provisionerName) + if err != nil { + failWithStatus(w, r, http.StatusNotFound, err) + return + } + + prov, ok := p.(*provisioner.EST) + if !ok { + failWithStatus(w, r, http.StatusBadRequest, errors.New("provisioner must be of type EST")) + return + } + + ctx = est.NewProvisionerContext(ctx, est.Provisioner(prov)) + next(w, r.WithContext(ctx)) + } +} + +func getCACerts(w http.ResponseWriter, r *http.Request) { + lookupProvisioner(getCACertsHandler)(w, r) +} + +func getCACertsHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + auth := est.MustFromContext(ctx) + + certs, err := auth.GetCACertificates(ctx) + if err != nil { + fail(w, r, fmt.Errorf("failed to get CA certificates: %w", err)) + return + } + + data, err := auth.BuildResponse(ctx, certs) + if err != nil { + fail(w, r, fmt.Errorf("failed to encode CA certificates: %w", err)) + return + } + + writeResponse(w, data, "application/pkcs7-mime; smime-type=certs-only", http.StatusOK) +} + +func getCSRAttrs(w http.ResponseWriter, r *http.Request) { + lookupProvisioner(getCSRAttrsHandler)(w, r) +} + +func getCSRAttrsHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + prov := est.ProvisionerFromContext(ctx) + + attrs, err := prov.GetCSRAttributes(ctx) + if err != nil { + fail(w, r, fmt.Errorf("failed to get CSR attributes: %w", err)) + return + } + if attrs == nil { + attrs = []byte{} + } + // Minimal implementation: allow provisioner to return nil/empty for "no attributes". + writeResponse(w, attrs, "application/csrattrs", http.StatusOK) +} + +func enroll(w http.ResponseWriter, r *http.Request) { + lookupProvisioner(enrollHandler)(w, r) +} + +func enrollHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx, err := authContextFromRequest(ctx, r) + if err != nil { + failWithStatus(w, r, http.StatusUnauthorized, err) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxPayloadSize)) + if err != nil { + failWithStatus(w, r, http.StatusBadRequest, fmt.Errorf("failed reading request body: %w", err)) + return + } + + if err := requireContentType(r, "application/pkcs10"); err != nil { + failWithStatus(w, r, http.StatusUnsupportedMediaType, err) + return + } + + der, err := decodeBase64Payload(body) + if err != nil { + failWithStatus(w, r, http.StatusBadRequest, err) + return + } + + csr, err := parseCSR(der) + if err != nil { + failWithStatus(w, r, http.StatusBadRequest, fmt.Errorf("failed parsing CSR: %w", err)) + return + } + if err := csr.CheckSignature(); err != nil { + failWithStatus(w, r, http.StatusBadRequest, fmt.Errorf("invalid CSR signature: %w", err)) + return + } + + opts, err := authorizeEnrollRequest(ctx, csr) + if err != nil { + failWithStatus(w, r, http.StatusUnauthorized, err) + return + } + + r = r.WithContext(ctx) + auth := est.MustFromContext(ctx) + + cert, err := auth.SignCSR(ctx, csr, opts...) + if err != nil { + failWithStatus(w, r, http.StatusInternalServerError, fmt.Errorf("failed issuing certificate: %w", err)) + return + } + + response, err := auth.BuildResponse(ctx, []*x509.Certificate{cert}) + if err != nil { + failWithStatus(w, r, http.StatusInternalServerError, fmt.Errorf("failed encoding issued certificate: %w", err)) + return + } + + writeResponse(w, response, "application/pkcs7-mime; smime-type=certs-only", http.StatusOK) +} + +var errMissingAuth = errors.New("missing authentication material") + +// authContextFromRequest extracts auth material from the request into the context. +func authContextFromRequest(ctx context.Context, r *http.Request) (context.Context, error) { + if r.TLS == nil { + return ctx, errors.New("missing TLS connection") + } + prov := est.ProvisionerFromContext(ctx) + cfg := prov.GetClientCertificateConfig() + + if cfg.Enable { + if cfg.ForwardedTLSClientCertHeader != "" { + // When a forwarded header is configured, only use it — never + // fall back to r.TLS.PeerCertificates, which would be the + // proxy's own certificate, not the actual client's. + if forwardedtlsClientCert := r.Header.Get(cfg.ForwardedTLSClientCertHeader); forwardedtlsClientCert != "" { + certDER, err := base64.StdEncoding.DecodeString(forwardedtlsClientCert) + if err != nil { + return ctx, fmt.Errorf("failed to decode client certificate from forwarded header: %w", err) + } + certs, err := x509.ParseCertificates(certDER) + if err != nil { + return ctx, fmt.Errorf("failed to parse client certificate from forwarded header: %w", err) + } + if len(certs) == 0 { + return ctx, errors.New("no certificates found in forwarded header") + } + ctx = est.NewClientCertificateContext(ctx, certs[0]) + ctx = est.NewClientCertificateChainContext(ctx, certs) + } + } else if len(r.TLS.PeerCertificates) > 0 { + ctx = est.NewClientCertificateContext(ctx, r.TLS.PeerCertificates[0]) + ctx = est.NewClientCertificateChainContext(ctx, r.TLS.PeerCertificates) + } + } + + if authHeader := r.Header.Get("Authorization"); authHeader != "" { + ctx = est.NewAuthenticationHeaderContext(ctx, authHeader) + if token, ok := BearerToken(r); ok { + ctx = est.NewBearerTokenContext(ctx, token) + } else if username, password, ok := r.BasicAuth(); ok { + ctx = est.NewBasicAuthContext(ctx, est.BasicAuth{ + Username: username, + Password: password, + }) + } + } + + if _, ok := est.ClientCertificateFromContext(ctx); !ok { + if _, ok := est.AuthenticationHeaderFromContext(ctx); !ok { + return ctx, errMissingAuth + } + } + return ctx, nil +} + +// authorizeEnrollRequest validates the request against provisioner-configured auth methods. +func authorizeEnrollRequest(ctx context.Context, csr *x509.CertificateRequest) ([]provisioner.SignCSROption, error) { + prov := est.ProvisionerFromContext(ctx) + ca := authority.MustFromContext(ctx) + + req := provest.AuthRequest{ + CSR: csr, + CARoots: ca.GetRootCertificates(), + CAIntermediates: ca.GetIntermediateCertificates(), + } + if cert, ok := est.ClientCertificateFromContext(ctx); ok { + req.ClientCertificate = cert + req.ClientCertificateChain, _ = est.ClientCertificateChainFromContext(ctx) + } + if authHeader, ok := est.AuthenticationHeaderFromContext(ctx); ok { + req.AuthenticationHeader = authHeader + if auth, ok := est.BasicAuthFromContext(ctx); ok { + req.BasicAuthUsername = auth.Username + req.BasicAuthPassword = auth.Password + } + if token, ok := est.BearerTokenFromContext(ctx); ok { + req.BearerToken = token + } + } + + opts, err := prov.AuthorizeRequest(ctx, req) + if err != nil { + return nil, err + } + return opts, nil +} + +func parseCSR(body []byte) (*x509.CertificateRequest, error) { + if len(body) == 0 { + return nil, errors.New("empty body") + } + + return x509.ParseCertificateRequest(body) +} + +func decodeBase64Payload(body []byte) ([]byte, error) { + if len(body) == 0 { + return nil, errors.New("empty body") + } + + trimmed := strings.Map(func(r rune) rune { + switch r { + case ' ', '\n', '\r', '\t': + return -1 + default: + return r + } + }, string(body)) + + if trimmed == "" { + return nil, errors.New("empty base64 payload") + } + + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(trimmed))) + n, err := base64.StdEncoding.Decode(decoded, []byte(trimmed)) + if err != nil { + return nil, fmt.Errorf("invalid base64 payload: %w", err) + } + + return decoded[:n], nil +} + +func requireContentType(r *http.Request, want string) error { + ct := r.Header.Get("Content-Type") + if ct == "" { + return errors.New("missing Content-Type header") + } + mt, _, err := mime.ParseMediaType(ct) + if err != nil { + return fmt.Errorf("invalid Content-Type header: %w", err) + } + if mt != want { + return fmt.Errorf("unsupported Content-Type %q", mt) + } + return nil +} + +func writeResponse(w http.ResponseWriter, data []byte, contentType string, status int) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(status) + + encoder := base64.NewEncoder(base64.StdEncoding, w) + _, _ = encoder.Write(data) + _ = encoder.Close() +} + +func fail(w http.ResponseWriter, r *http.Request, err error) { + log.Error(w, r, err) + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func failWithStatus(w http.ResponseWriter, r *http.Request, status int, err error) { + log.Error(w, r, err) + http.Error(w, err.Error(), status) +} diff --git a/est/api/api_test.go b/est/api/api_test.go new file mode 100644 index 000000000..ecaebe564 --- /dev/null +++ b/est/api/api_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_writeResponse(t *testing.T) { + type args struct { + w http.ResponseWriter + r *http.Request + data []byte + contentType string + status int + } + tests := []struct { + name string + args args + wantBody string + wantHeaders map[string]string + }{ + { + name: "ok", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/", nil), + data: []byte("hello world"), + contentType: "application/pkcs7-mime; smime-type=certs-only", + status: http.StatusOK, + }, + wantBody: base64.StdEncoding.EncodeToString([]byte("hello world")), + wantHeaders: map[string]string{ + "Content-Type": "application/pkcs7-mime; smime-type=certs-only", + "Content-Transfer-Encoding": "base64", + }, + }, + { + name: "ok/csrattrs", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "/", nil), + data: []byte("attribute data"), + contentType: "application/csrattrs", + status: http.StatusOK, + }, + wantBody: base64.StdEncoding.EncodeToString([]byte("attribute data")), + wantHeaders: map[string]string{ + "Content-Type": "application/csrattrs", + "Content-Transfer-Encoding": "base64", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writeResponse(tt.args.w, tt.args.data, tt.args.contentType, tt.args.status) + resp := tt.args.w.(*httptest.ResponseRecorder) + + assert.Equal(t, tt.args.status, resp.Code) + assert.Equal(t, tt.wantBody, resp.Body.String()) + + for k, v := range tt.wantHeaders { + assert.Equal(t, v, resp.Header().Get(k)) + } + }) + } +} diff --git a/est/auth_context.go b/est/auth_context.go new file mode 100644 index 000000000..687e17d1e --- /dev/null +++ b/est/auth_context.go @@ -0,0 +1,58 @@ +package est + +import "context" + +// AuthenticationHeaderKey is the context key used to store the EST authentication header. +type AuthenticationHeaderKey struct{} + +// NewAuthenticationHeaderContext stores the EST authentication header in the context. +func NewAuthenticationHeaderContext(ctx context.Context, header string) context.Context { + if header == "" { + return ctx + } + return context.WithValue(ctx, AuthenticationHeaderKey{}, header) +} + +// AuthenticationHeaderFromContext returns the EST authentication header stored in the context. +func AuthenticationHeaderFromContext(ctx context.Context) (string, bool) { + header, ok := ctx.Value(AuthenticationHeaderKey{}).(string) + return header, ok +} + +// BasicAuth holds the HTTP basic auth credentials for an EST request. +type BasicAuth struct { + Username string + Password string +} + +type basicAuthKey struct{} + +// NewBasicAuthContext stores the HTTP basic auth credentials in the context. +func NewBasicAuthContext(ctx context.Context, auth BasicAuth) context.Context { + if auth.Username == "" && auth.Password == "" { + return ctx + } + return context.WithValue(ctx, basicAuthKey{}, auth) +} + +// BasicAuthFromContext returns the HTTP basic auth credentials stored in the context. +func BasicAuthFromContext(ctx context.Context) (BasicAuth, bool) { + auth, ok := ctx.Value(basicAuthKey{}).(BasicAuth) + return auth, ok +} + +type BearerTokenKey struct{} + +// NewBearerTokenContext stores the HTTP bearer token in the context. +func NewBearerTokenContext(ctx context.Context, token string) context.Context { + if token == "" { + return ctx + } + return context.WithValue(ctx, BearerTokenKey{}, token) +} + +// BearerTokenFromContext returns the HTTP bearer token stored in the context. +func BearerTokenFromContext(ctx context.Context) (string, bool) { + token, ok := ctx.Value(BearerTokenKey{}).(string) + return token, ok +} diff --git a/est/authority.go b/est/authority.go new file mode 100644 index 000000000..1e1198020 --- /dev/null +++ b/est/authority.go @@ -0,0 +1,165 @@ +package est + +import ( + "bytes" + "context" + "crypto/x509" + "fmt" + + "github.com/smallstep/pkcs7" + + "go.step.sm/crypto/x509util" + + "github.com/smallstep/certificates/authority/provisioner" +) + +// Authority handles EST interactions. +type Authority struct { + signAuth SignAuthority + roots []*x509.Certificate + intermediates []*x509.Certificate +} + +type authorityKey struct{} + +// NewContext adds the given authority to the context. +func NewContext(ctx context.Context, a *Authority) context.Context { + return context.WithValue(ctx, authorityKey{}, a) +} + +// FromContext returns the current authority from the given context. +func FromContext(ctx context.Context) (a *Authority, ok bool) { + a, ok = ctx.Value(authorityKey{}).(*Authority) + return +} + +// MustFromContext returns the current authority from the given context. It will +// panic if the authority is not in the context. +func MustFromContext(ctx context.Context) *Authority { + var ( + a *Authority + ok bool + ) + if a, ok = FromContext(ctx); !ok { + panic("est authority is not in the context") + } + return a +} + +// SignAuthority is the interface for a signing authority. +type SignAuthority interface { + SignWithContext(ctx context.Context, cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) + LoadProvisionerByName(string) (provisioner.Interface, error) +} + +// New returns a new Authority that implements the EST interface. +func New(signAuth SignAuthority, opts Options) (*Authority, error) { + if err := opts.Validate(); err != nil { + return nil, err + } + + return &Authority{ + signAuth: signAuth, + roots: opts.Roots, + intermediates: opts.Intermediates, + }, nil +} + +// LoadProvisionerByName calls out to the SignAuthority interface to load a +// provisioner by name. +func (a *Authority) LoadProvisionerByName(name string) (provisioner.Interface, error) { + return a.signAuth.LoadProvisionerByName(name) +} + +// GetCACertificates returns the certificate chain for the CA. +func (a *Authority) GetCACertificates(ctx context.Context) (certs []*x509.Certificate, err error) { + certs = append(a.intermediates, a.roots...) + + return certs, nil +} + +// SignCSR signs the CSR using the provisioner and returns the issued chain. +func (a *Authority) SignCSR(ctx context.Context, csr *x509.CertificateRequest, signCSROpts ...provisioner.SignCSROption) (*x509.Certificate, error) { + // TODO: intermediate storage of the request? In EST it's possible to request a csr/certificate + // to be signed, which can be performed asynchronously / out-of-band. In that case a client can + // poll for the status. It seems to be similar as what can happen in ACME and SCEP, so might want to model + // the implementation after the one in the ACME authority. Requires storage, etc. + // ref: https://datatracker.ietf.org/doc/html/rfc7030#section-4.2.3 + p := provisionerFromContext(ctx) + + // Template data + sans := []string{} + sans = append(sans, csr.DNSNames...) + sans = append(sans, csr.EmailAddresses...) + for _, v := range csr.IPAddresses { + sans = append(sans, v.String()) + } + for _, v := range csr.URIs { + sans = append(sans, v.String()) + } + if len(sans) == 0 { + sans = append(sans, csr.Subject.CommonName) + } + data := x509util.CreateTemplateData(csr.Subject.CommonName, sans) + data.SetCertificateRequest(csr) + data.SetSubject(x509util.Subject{ + Country: csr.Subject.Country, + Organization: csr.Subject.Organization, + OrganizationalUnit: csr.Subject.OrganizationalUnit, + Locality: csr.Subject.Locality, + Province: csr.Subject.Province, + StreetAddress: csr.Subject.StreetAddress, + PostalCode: csr.Subject.PostalCode, + SerialNumber: csr.Subject.SerialNumber, + CommonName: csr.Subject.CommonName, + }) + + for _, o := range signCSROpts { + if m, ok := o.(provisioner.TemplateDataModifier); ok { + m.Modify(data) + } + } + + ctx = provisioner.NewContextWithMethod(ctx, provisioner.SignMethod) + signOps, err := p.AuthorizeSign(ctx, "") + if err != nil { + return nil, fmt.Errorf("error retrieving authorization options from EST provisioner: %w", err) + } + for _, signOp := range signOps { + if wc, ok := signOp.(*provisioner.WebhookController); ok { + wc.TemplateData = data + } + } + + opts := provisioner.SignOptions{} + templateOptions, err := provisioner.TemplateOptions(p.GetOptions(), data) + if err != nil { + return nil, fmt.Errorf("error creating template options from EST provisioner: %w", err) + } + signOps = append(signOps, templateOptions) + + certChain, err := a.signAuth.SignWithContext(ctx, csr, opts, signOps...) + if err != nil { + return nil, fmt.Errorf("error generating certificate: %w", err) + } + + // return leaf certificate (only): https://datatracker.ietf.org/doc/html/rfc7030#section-4.2.3 + return certChain[0], nil +} + +// BuildResponse returns a certs-only PKCS7 SignedData for the given certs. +func (a *Authority) BuildResponse(ctx context.Context, certs []*x509.Certificate) ([]byte, error) { + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates to encode") + } + // Build degenerate PKCS7: SignedData with no encapsulated content or signer infos. + var buf bytes.Buffer + for _, cert := range certs { + buf.Write(cert.Raw) + } + degenerate, err := pkcs7.DegenerateCertificate(buf.Bytes()) + if err != nil { + return nil, err + } + return degenerate, nil +} diff --git a/est/client_cert.go b/est/client_cert.go new file mode 100644 index 000000000..14c26f291 --- /dev/null +++ b/est/client_cert.go @@ -0,0 +1,37 @@ +package est + +import ( + "context" + "crypto/x509" +) + +type clientCertificateKey struct{} +type clientCertificateChainKey struct{} + +// NewClientCertificateContext stores the TLS client certificate in the context. +func NewClientCertificateContext(ctx context.Context, cert *x509.Certificate) context.Context { + if cert == nil { + return ctx + } + return context.WithValue(ctx, clientCertificateKey{}, cert) +} + +// ClientCertificateFromContext returns the TLS client certificate stored in the context. +func ClientCertificateFromContext(ctx context.Context) (*x509.Certificate, bool) { + cert, ok := ctx.Value(clientCertificateKey{}).(*x509.Certificate) + return cert, ok +} + +// NewClientCertificateChainContext stores the TLS client certificate chain in the context. +func NewClientCertificateChainContext(ctx context.Context, chain []*x509.Certificate) context.Context { + if len(chain) == 0 { + return ctx + } + return context.WithValue(ctx, clientCertificateChainKey{}, chain) +} + +// ClientCertificateChainFromContext returns the TLS client certificate chain stored in the context. +func ClientCertificateChainFromContext(ctx context.Context) ([]*x509.Certificate, bool) { + chain, ok := ctx.Value(clientCertificateChainKey{}).([]*x509.Certificate) + return chain, ok +} diff --git a/est/options.go b/est/options.go new file mode 100644 index 000000000..9e27d14b0 --- /dev/null +++ b/est/options.go @@ -0,0 +1,24 @@ +package est + +import ( + "crypto/x509" + "errors" +) + +// Options configures the EST authority instance. +type Options struct { + Roots []*x509.Certificate `json:"-"` + Intermediates []*x509.Certificate `json:"-"` +} + +// Validate checks the fields in Options. +func (o *Options) Validate() error { + switch { + case len(o.Roots) == 0: + return errors.New("no root certificate available for EST authority") + case len(o.Intermediates) == 0: + return errors.New("no intermediate certificate available for EST authority") + } + + return nil +} diff --git a/est/provisioner.go b/est/provisioner.go new file mode 100644 index 000000000..c682a0d47 --- /dev/null +++ b/est/provisioner.go @@ -0,0 +1,41 @@ +package est + +import ( + "context" + + "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/authority/provisioner/est" +) + +// Provisioner is an interface that embeds the generic provisioner.Interface and +// adds EST-specific helpers. +type Provisioner interface { + provisioner.Interface + GetOptions() *provisioner.Options + GetClientCertificateConfig() *provisioner.ClientCertificateConfig + AuthorizeRequest(ctx context.Context, req est.AuthRequest) ([]provisioner.SignCSROption, error) + GetCSRAttributes(ctx context.Context) ([]byte, error) +} + +// provisionerKey is the key type for storing and searching an EST provisioner in the context. +type provisionerKey struct{} + +// provisionerFromContext searches the context for an EST provisioner. +// Returns the provisioner or panics if no EST provisioner is found. +func provisionerFromContext(ctx context.Context) Provisioner { + p, ok := ctx.Value(provisionerKey{}).(Provisioner) + if !ok { + panic("EST provisioner expected in request context") + } + return p +} + +// NewProvisionerContext returns a new context with the EST provisioner set. +func NewProvisionerContext(ctx context.Context, p Provisioner) context.Context { + return context.WithValue(ctx, provisionerKey{}, p) +} + +// ProvisionerFromContext returns the EST provisioner stored in the context. +func ProvisionerFromContext(ctx context.Context) Provisioner { + return provisionerFromContext(ctx) +} diff --git a/go.mod b/go.mod index 180d30b66..313d70b57 100644 --- a/go.mod +++ b/go.mod @@ -173,3 +173,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect ) + +replace github.com/smallstep/linkedca => github.com/jbpin/linkedca v0.0.0-20260728083914-c76a61c2706f diff --git a/go.sum b/go.sum index 2ba4cdcd6..97e521ed0 100644 --- a/go.sum +++ b/go.sum @@ -261,6 +261,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbpin/linkedca v0.0.0-20260728083914-c76a61c2706f h1:TwoMh5P12Fzoiw7iNsvDE8ABEpnJMQX+CYj348F5548= +github.com/jbpin/linkedca v0.0.0-20260728083914-c76a61c2706f/go.mod h1:Z8c7EgVrSHNhshIhRnUGfKfE796+TwF2eyErhqANmJQ= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= @@ -360,8 +362,6 @@ github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9do github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y= github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68 h1:KcK2guFXrE5sX/nvF1b+atHP6DjRB1gCv3ppyTXB2Zk= github.com/smallstep/go-attestation v0.4.4-0.20260814222900-a849f4e2cd68/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= -github.com/smallstep/linkedca v0.26.0 h1:NsxTVo3zI3KwOFFiVodeHtuGgKq0b5kOUXVLjjNTvXY= -github.com/smallstep/linkedca v0.26.0/go.mod h1:Z8c7EgVrSHNhshIhRnUGfKfE796+TwF2eyErhqANmJQ= github.com/smallstep/nosql v0.8.0 h1:FBTCUfKPmWYbrozW+RBKu+fnvbn+zr5rVli/XB4Jp4A= github.com/smallstep/nosql v0.8.0/go.mod h1:5dUpNotHLHhOUapP0PLBVVfp3tG1DFC31VRccg+Cqwo= github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= diff --git a/webhook/options.go b/webhook/options.go index 62f0170ae..388b97c9b 100644 --- a/webhook/options.go +++ b/webhook/options.go @@ -134,7 +134,44 @@ func WithX5CCertificate(leaf *x509.Certificate) RequestBodyOption { } rb.X5CCertificate.PublicKey = key } + return nil + } +} + +func WithClientCertificate(cert *x509.Certificate) RequestBodyOption { + return func(rb *RequestBody) error { + certificate, err := x509util.NewCertificateFromX509(cert) + if err != nil { + return err + } + rb.ClientCertificate = &X509Certificate{ + Raw: cert.Raw, + PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + Certificate: certificate, + } + if cert.PublicKey != nil { + key, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + return err + } + rb.ClientCertificate.PublicKey = key + } + return nil + } +} + +func WithAuthenticationHeader(header string) RequestBodyOption { + return func(rb *RequestBody) error { + rb.AuthenticationHeader = header + return nil + } +} +func WithBearerToken(token string) RequestBodyOption { + return func(rb *RequestBody) error { + rb.BearerToken = token return nil } } diff --git a/webhook/types.go b/webhook/types.go index c60de7099..a78356c11 100644 --- a/webhook/types.go +++ b/webhook/types.go @@ -102,4 +102,8 @@ type RequestBody struct { X5CCertificate *X5CCertificate `json:"x5cCertificate,omitempty"` // Set for X5C, AWS, GCP, and Azure provisioners AuthorizationPrincipal string `json:"authorizationPrincipal,omitempty"` + // Set for EST webhook requests + AuthenticationHeader string `json:"authenticationHeader,omitempty"` + BearerToken string `json:"bearerToken,omitempty"` + ClientCertificate *X509Certificate `json:"clientCertificate,omitempty"` }