From 7b5d6968a5e4b7ded7d8f1f30e0c6dba62ab6e2e Mon Sep 17 00:00:00 2001 From: max furman Date: Mon, 3 May 2021 12:48:20 -0700 Subject: [PATCH] first commit --- acme/db/nosql/nosql.go | 7 +- api/api.go | 3 +- api/sign.go | 12 +- api/ssh.go | 15 +- authority/admin.go | 12 + authority/authority.go | 41 ++- authority/{ => config}/config.go | 26 +- authority/{ => config}/config_test.go | 2 +- authority/config/ssh.go | 94 ++++++ authority/{ => config}/tls_options.go | 2 +- authority/{ => config}/tls_options_test.go | 2 +- authority/{ => config}/types.go | 2 +- authority/{ => config}/types_test.go | 2 +- authority/mgmt/api/admin.go | 112 +++++++ authority/mgmt/api/authConfig.go | 121 +++++++ authority/mgmt/api/handler.go | 50 +++ authority/mgmt/api/provisioner.go | 122 +++++++ authority/mgmt/config.go | 357 +++++++++++++++++++++ authority/mgmt/db.go | 149 +++++++++ authority/mgmt/db/nosql/admin.go | 163 ++++++++++ authority/mgmt/db/nosql/authConfig.go | 113 +++++++ authority/mgmt/db/nosql/nosql.go | 91 ++++++ authority/mgmt/db/nosql/provisioner.go | 174 ++++++++++ authority/mgmt/errors.go | 191 +++++++++++ authority/options.go | 9 +- authority/provisioner/claims.go | 21 ++ authority/ssh.go | 104 +----- authority/tls.go | 5 +- ca/bootstrap.go | 88 ++--- ca/ca.go | 12 +- ca/mgmtClient.go | 107 ++++++ ca/tls.go | 1 - commands/app.go | 4 +- commands/onboard.go | 4 +- pki/pki.go | 26 +- 35 files changed, 2032 insertions(+), 212 deletions(-) create mode 100644 authority/admin.go rename authority/{ => config}/config.go (93%) rename authority/{ => config}/config_test.go (99%) create mode 100644 authority/config/ssh.go rename authority/{ => config}/tls_options.go (99%) rename authority/{ => config}/tls_options_test.go (99%) rename authority/{ => config}/types.go (98%) rename authority/{ => config}/types_test.go (99%) create mode 100644 authority/mgmt/api/admin.go create mode 100644 authority/mgmt/api/authConfig.go create mode 100644 authority/mgmt/api/handler.go create mode 100644 authority/mgmt/api/provisioner.go create mode 100644 authority/mgmt/config.go create mode 100644 authority/mgmt/db.go create mode 100644 authority/mgmt/db/nosql/admin.go create mode 100644 authority/mgmt/db/nosql/authConfig.go create mode 100644 authority/mgmt/db/nosql/nosql.go create mode 100644 authority/mgmt/db/nosql/provisioner.go create mode 100644 authority/mgmt/errors.go create mode 100644 ca/mgmtClient.go diff --git a/acme/db/nosql/nosql.go b/acme/db/nosql/nosql.go index 052f5729..e2edd050 100644 --- a/acme/db/nosql/nosql.go +++ b/acme/db/nosql/nosql.go @@ -23,11 +23,12 @@ var ( // DB is a struct that implements the AcmeDB interface. type DB struct { - db nosqlDB.DB + db nosqlDB.DB + authorityID string } // New configures and returns a new ACME DB backend implemented using a nosql DB. -func New(db nosqlDB.DB) (*DB, error) { +func New(db nosqlDB.DB, authorityID string) (*DB, error) { tables := [][]byte{accountTable, accountByKeyIDTable, authzTable, challengeTable, nonceTable, orderTable, ordersByAccountIDTable, certTable} for _, b := range tables { @@ -36,7 +37,7 @@ func New(db nosqlDB.DB) (*DB, error) { string(b)) } } - return &DB{db}, nil + return &DB{db, authorityID}, nil } // save writes the new data to the database, overwriting the old data if it diff --git a/api/api.go b/api/api.go index 6a0a7e8f..d6cf9ab7 100644 --- a/api/api.go +++ b/api/api.go @@ -21,6 +21,7 @@ import ( "github.com/go-chi/chi" "github.com/pkg/errors" "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/logging" @@ -32,7 +33,7 @@ type Authority interface { // context specifies the Authorize[Sign|Revoke|etc.] method. Authorize(ctx context.Context, ott string) ([]provisioner.SignOption, error) AuthorizeSign(ott string) ([]provisioner.SignOption, error) - GetTLSOptions() *authority.TLSOptions + GetTLSOptions() *config.TLSOptions Root(shasum string) (*x509.Certificate, error) Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error) Renew(peer *x509.Certificate) ([]*x509.Certificate, error) diff --git a/api/sign.go b/api/sign.go index 69e9a1a5..d6fd2bc6 100644 --- a/api/sign.go +++ b/api/sign.go @@ -5,7 +5,7 @@ import ( "encoding/json" "net/http" - "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/errs" ) @@ -37,11 +37,11 @@ func (s *SignRequest) Validate() error { // SignResponse is the response object of the certificate signature request. type SignResponse struct { - ServerPEM Certificate `json:"crt"` - CaPEM Certificate `json:"ca"` - CertChainPEM []Certificate `json:"certChain"` - TLSOptions *authority.TLSOptions `json:"tlsOptions,omitempty"` - TLS *tls.ConnectionState `json:"-"` + ServerPEM Certificate `json:"crt"` + CaPEM Certificate `json:"ca"` + CertChainPEM []Certificate `json:"certChain"` + TLSOptions *config.TLSOptions `json:"tlsOptions,omitempty"` + TLS *tls.ConnectionState `json:"-"` } // Sign is an HTTP handler that reads a certificate request and an diff --git a/api/ssh.go b/api/ssh.go index 9962ad4f..8c0c1aa3 100644 --- a/api/ssh.go +++ b/api/ssh.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/templates" @@ -22,12 +23,12 @@ type SSHAuthority interface { RenewSSH(ctx context.Context, cert *ssh.Certificate) (*ssh.Certificate, error) RekeySSH(ctx context.Context, cert *ssh.Certificate, key ssh.PublicKey, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) SignSSHAddUser(ctx context.Context, key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error) - GetSSHRoots(ctx context.Context) (*authority.SSHKeys, error) - GetSSHFederation(ctx context.Context) (*authority.SSHKeys, error) + GetSSHRoots(ctx context.Context) (*config.SSHKeys, error) + GetSSHFederation(ctx context.Context) (*config.SSHKeys, error) GetSSHConfig(ctx context.Context, typ string, data map[string]string) ([]templates.Output, error) CheckSSHHost(ctx context.Context, principal string, token string) (bool, error) - GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([]authority.Host, error) - GetSSHBastion(ctx context.Context, user string, hostname string) (*authority.Bastion, error) + GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([]config.Host, error) + GetSSHBastion(ctx context.Context, user string, hostname string) (*config.Bastion, error) } // SSHSignRequest is the request body of an SSH certificate request. @@ -86,7 +87,7 @@ type SSHCertificate struct { // SSHGetHostsResponse is the response object that returns the list of valid // hosts for SSH. type SSHGetHostsResponse struct { - Hosts []authority.Host `json:"hosts"` + Hosts []config.Host `json:"hosts"` } // MarshalJSON implements the json.Marshaler interface. Returns a quoted, @@ -239,8 +240,8 @@ func (r *SSHBastionRequest) Validate() error { // SSHBastionResponse is the response body used to return the bastion for a // given host. type SSHBastionResponse struct { - Hostname string `json:"hostname"` - Bastion *authority.Bastion `json:"bastion,omitempty"` + Hostname string `json:"hostname"` + Bastion *config.Bastion `json:"bastion,omitempty"` } // SSHSign is an HTTP handler that reads an SignSSHRequest with a one-time-token diff --git a/authority/admin.go b/authority/admin.go new file mode 100644 index 00000000..6c95de4f --- /dev/null +++ b/authority/admin.go @@ -0,0 +1,12 @@ +package authority + +// Admin is the type definining Authority admins. Admins can update Authority +// configuration, provisioners, and even other admins. +type Admin struct { + ID string `json:"-"` + AuthorityID string `json:"-"` + Name string `json:"name"` + Provisioner string `json:"provisioner"` + IsSuperAdmin bool `json:"isSuperAdmin"` + IsDeleted bool `json:"isDeleted"` +} diff --git a/authority/authority.go b/authority/authority.go index 72fa081f..fcb01a03 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -13,6 +13,9 @@ import ( "github.com/smallstep/certificates/cas" "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/mgmt" + authMgmtNosql "github.com/smallstep/certificates/authority/mgmt/db/nosql" "github.com/smallstep/certificates/authority/provisioner" casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" @@ -20,17 +23,15 @@ import ( kmsapi "github.com/smallstep/certificates/kms/apiv1" "github.com/smallstep/certificates/kms/sshagentkms" "github.com/smallstep/certificates/templates" + "github.com/smallstep/nosql" "go.step.sm/crypto/pemutil" "golang.org/x/crypto/ssh" ) -const ( - legacyAuthority = "step-certificate-authority" -) - // Authority implements the Certificate Authority internal interface. type Authority struct { - config *Config + config *config.Config + mgmtDB *mgmt.DB keyManager kms.KeyManager provisioners *provisioner.Collection db db.AuthDB @@ -55,14 +56,14 @@ type Authority struct { startTime time.Time // Custom functions - sshBastionFunc func(ctx context.Context, user, hostname string) (*Bastion, error) + sshBastionFunc func(ctx context.Context, user, hostname string) (*config.Bastion, error) sshCheckHostFunc func(ctx context.Context, principal string, tok string, roots []*x509.Certificate) (bool, error) - sshGetHostsFunc func(ctx context.Context, cert *x509.Certificate) ([]Host, error) + sshGetHostsFunc func(ctx context.Context, cert *x509.Certificate) ([]config.Host, error) getIdentityFunc provisioner.GetIdentityFunc } // New creates and initiates a new Authority type. -func New(config *Config, opts ...Option) (*Authority, error) { +func New(config *config.Config, opts ...Option) (*Authority, error) { err := config.Validate() if err != nil { return nil, err @@ -92,7 +93,7 @@ func New(config *Config, opts ...Option) (*Authority, error) { // project without the limitations of the config. func NewEmbedded(opts ...Option) (*Authority, error) { a := &Authority{ - config: &Config{}, + config: &config.Config{}, certificates: new(sync.Map), } @@ -116,7 +117,7 @@ func NewEmbedded(opts ...Option) (*Authority, error) { } // Initialize config required fields. - a.config.init() + a.config.Init() // Initialize authority from options or configuration. if err := a.init(); err != nil { @@ -143,6 +144,22 @@ func (a *Authority) init() error { } } + // Pull AuthConfig from DB. + if true { + mgmtDB, err := authMgmtNosql.New(a.db.(nosql.DB), mgmt.DefaultAuthorityID) + if err != nil { + return err + } + _ac, err := mgmtDB.GetAuthConfig(context.Background(), mgmt.DefaultAuthorityID) + if err != nil { + return err + } + a.config.AuthorityConfig, err = _ac.ToCertificates() + if err != nil { + return err + } + } + // Initialize key manager if it has not been set in the options. if a.keyManager == nil { var options kmsapi.Options @@ -314,7 +331,7 @@ func (a *Authority) init() error { } // Merge global and configuration claims - claimer, err := provisioner.NewClaimer(a.config.AuthorityConfig.Claims, globalProvisionerClaims) + claimer, err := provisioner.NewClaimer(a.config.AuthorityConfig.Claims, config.GlobalProvisionerClaims) if err != nil { return err } @@ -326,7 +343,7 @@ func (a *Authority) init() error { return err } // Initialize provisioners - audiences := a.config.getAudiences() + audiences := a.config.GetAudiences() a.provisioners = provisioner.NewCollection(audiences) config := provisioner.Config{ Claims: claimer.Claims(), diff --git a/authority/config.go b/authority/config/config.go similarity index 93% rename from authority/config.go rename to authority/config/config.go index b02bc2be..1f6b7619 100644 --- a/authority/config.go +++ b/authority/config/config.go @@ -1,4 +1,4 @@ -package authority +package config import ( "encoding/json" @@ -15,6 +15,10 @@ import ( "github.com/smallstep/certificates/templates" ) +const ( + legacyAuthority = "step-certificate-authority" +) + var ( // DefaultTLSOptions represents the default TLS version as well as the cipher // suites used in the TLS certificates. @@ -28,10 +32,12 @@ var ( MaxVersion: 1.2, Renegotiation: false, } - defaultBackdate = time.Minute - defaultDisableRenewal = false - defaultEnableSSHCA = false - globalProvisionerClaims = provisioner.Claims{ + defaultBackdate = time.Minute + defaultDisableRenewal = false + defaultEnableSSHCA = false + // GlobalProvisionerClaims default claims for the Authority. Can be overriden + // by provisioner specific claims. + GlobalProvisionerClaims = provisioner.Claims{ MinTLSDur: &provisioner.Duration{Duration: 5 * time.Minute}, // TLS certs MaxTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, DefaultTLSDur: &provisioner.Duration{Duration: 24 * time.Hour}, @@ -151,9 +157,9 @@ func LoadConfiguration(filename string) (*Config, error) { return &c, nil } -// initializes the minimal configuration required to create an authority. This +// Init initializes the minimal configuration required to create an authority. This // is mainly used on embedded authorities. -func (c *Config) init() { +func (c *Config) Init() { if c.DNSNames == nil { c.DNSNames = []string{"localhost", "127.0.0.1", "::1"} } @@ -246,13 +252,13 @@ func (c *Config) Validate() error { return err } - return c.AuthorityConfig.Validate(c.getAudiences()) + return c.AuthorityConfig.Validate(c.GetAudiences()) } -// getAudiences returns the legacy and possible urls without the ports that will +// GetAudiences returns the legacy and possible urls without the ports that will // be used as the default provisioner audiences. The CA might have proxies in // front so we cannot rely on the port. -func (c *Config) getAudiences() provisioner.Audiences { +func (c *Config) GetAudiences() provisioner.Audiences { audiences := provisioner.Audiences{ Sign: []string{legacyAuthority}, Revoke: []string{legacyAuthority}, diff --git a/authority/config_test.go b/authority/config/config_test.go similarity index 99% rename from authority/config_test.go rename to authority/config/config_test.go index 87cd3fba..735ac33e 100644 --- a/authority/config_test.go +++ b/authority/config/config_test.go @@ -1,4 +1,4 @@ -package authority +package config import ( "fmt" diff --git a/authority/config/ssh.go b/authority/config/ssh.go new file mode 100644 index 00000000..4ba1bb38 --- /dev/null +++ b/authority/config/ssh.go @@ -0,0 +1,94 @@ +package config + +import ( + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/provisioner" + "go.step.sm/crypto/jose" + "golang.org/x/crypto/ssh" +) + +// SSHConfig contains the user and host keys. +type SSHConfig struct { + HostKey string `json:"hostKey"` + UserKey string `json:"userKey"` + Keys []*SSHPublicKey `json:"keys,omitempty"` + AddUserPrincipal string `json:"addUserPrincipal,omitempty"` + AddUserCommand string `json:"addUserCommand,omitempty"` + Bastion *Bastion `json:"bastion,omitempty"` +} + +// Bastion contains the custom properties used on bastion. +type Bastion struct { + Hostname string `json:"hostname"` + User string `json:"user,omitempty"` + Port string `json:"port,omitempty"` + Command string `json:"cmd,omitempty"` + Flags string `json:"flags,omitempty"` +} + +// HostTag are tagged with k,v pairs. These tags are how a user is ultimately +// associated with a host. +type HostTag struct { + ID string + Name string + Value string +} + +// Host defines expected attributes for an ssh host. +type Host struct { + HostID string `json:"hid"` + HostTags []HostTag `json:"host_tags"` + Hostname string `json:"hostname"` +} + +// Validate checks the fields in SSHConfig. +func (c *SSHConfig) Validate() error { + if c == nil { + return nil + } + for _, k := range c.Keys { + if err := k.Validate(); err != nil { + return err + } + } + return nil +} + +// SSHPublicKey contains a public key used by federated CAs to keep old signing +// keys for this ca. +type SSHPublicKey struct { + Type string `json:"type"` + Federated bool `json:"federated"` + Key jose.JSONWebKey `json:"key"` + publicKey ssh.PublicKey +} + +// Validate checks the fields in SSHPublicKey. +func (k *SSHPublicKey) Validate() error { + switch { + case k.Type == "": + return errors.New("type cannot be empty") + case k.Type != provisioner.SSHHostCert && k.Type != provisioner.SSHUserCert: + return errors.Errorf("invalid type %s, it must be user or host", k.Type) + case !k.Key.IsPublic(): + return errors.New("invalid key type, it must be a public key") + } + + key, err := ssh.NewPublicKey(k.Key.Key) + if err != nil { + return errors.Wrap(err, "error creating ssh key") + } + k.publicKey = key + return nil +} + +// PublicKey returns the ssh public key. +func (k *SSHPublicKey) PublicKey() ssh.PublicKey { + return k.publicKey +} + +// SSHKeys represents the SSH User and Host public keys. +type SSHKeys struct { + UserKeys []ssh.PublicKey + HostKeys []ssh.PublicKey +} diff --git a/authority/tls_options.go b/authority/config/tls_options.go similarity index 99% rename from authority/tls_options.go rename to authority/config/tls_options.go index 3edde605..5b0575d6 100644 --- a/authority/tls_options.go +++ b/authority/config/tls_options.go @@ -1,4 +1,4 @@ -package authority +package config import ( "crypto/tls" diff --git a/authority/tls_options_test.go b/authority/config/tls_options_test.go similarity index 99% rename from authority/tls_options_test.go rename to authority/config/tls_options_test.go index 96c58c5d..d7ccb20b 100644 --- a/authority/tls_options_test.go +++ b/authority/config/tls_options_test.go @@ -1,4 +1,4 @@ -package authority +package config import ( "crypto/tls" diff --git a/authority/types.go b/authority/config/types.go similarity index 98% rename from authority/types.go rename to authority/config/types.go index 0d0f2a90..6d7b9389 100644 --- a/authority/types.go +++ b/authority/config/types.go @@ -1,4 +1,4 @@ -package authority +package config import ( "encoding/json" diff --git a/authority/types_test.go b/authority/config/types_test.go similarity index 99% rename from authority/types_test.go rename to authority/config/types_test.go index 352c253f..b1a874d6 100644 --- a/authority/types_test.go +++ b/authority/config/types_test.go @@ -1,4 +1,4 @@ -package authority +package config import ( "reflect" diff --git a/authority/mgmt/api/admin.go b/authority/mgmt/api/admin.go new file mode 100644 index 00000000..6f6aaa93 --- /dev/null +++ b/authority/mgmt/api/admin.go @@ -0,0 +1,112 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/smallstep/certificates/api" +) + +// CreateAdminRequest represents the body for a CreateAdmin request. +type CreateAdminRequest struct { + Name string `json:"name"` + Provisioner string `json:"provisioner"` + IsSuperAdmin bool `json:"isSuperAdmin"` +} + +// Validate validates a new-admin request body. +func (car *CreateAdminRequest) Validate() error { + return nil +} + +// UpdateAdminRequest represents the body for a UpdateAdmin request. +type UpdateAdminRequest struct { + Name string `json:"name"` + Provisioner string `json:"provisioner"` + IsSuperAdmin bool `json:"isSuperAdmin"` +} + +// Validate validates a new-admin request body. +func (uar *UpdateAdminRequest) Validate() error { + return nil +} + +// GetAdmin returns the requested admin, or an error. +func (h *Handler) GetAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "id") + + prov, err := h.db.GetAdmin(ctx, id) + if err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, prov) +} + +// GetAdmins returns all admins associated with the authority. +func (h *Handler) GetAdmins(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + admins, err := h.db.GetAdmins(ctx) + if err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, admins) +} + +// CreateAdmin creates a new admin. +func (h *Handler) CreateAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var body CreateAdminRequest + if err := ReadJSON(r.Body, &body); err != nil { + api.WriteError(w, err) + return + } + if err := body.Validate(); err != nil { + api.WriteError(w, err) + } + + adm := &config.Admin{ + Name: body.Name, + Provisioner: body.Provisioner, + IsSuperAdmin: body.IsSuperAdmin, + } + if err := h.db.CreateAdmin(ctx, adm); err != nil { + api.WriteError(w, err) + return + } + api.JSONStatus(w, adm, http.StatusCreated) +} + +// UpdateAdmin updates an existing admin. +func (h *Handler) UpdateAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "id") + + var body UpdateAdminRequest + if err := ReadJSON(r.Body, &body); err != nil { + api.WriteError(w, err) + return + } + if err := body.Validate(); err != nil { + api.WriteError(w, err) + return + } + if adm, err := h.db.GetAdmin(ctx, id); err != nil { + api.WriteError(w, err) + return + } + + adm.Name = body.Name + adm.Provisioner = body.Provisioner + adm.IsSuperAdmin = body.IsSuperAdmin + + if err := h.db.UpdateAdmin(ctx, adm); err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, adm) +} diff --git a/authority/mgmt/api/authConfig.go b/authority/mgmt/api/authConfig.go new file mode 100644 index 00000000..fb23f8e8 --- /dev/null +++ b/authority/mgmt/api/authConfig.go @@ -0,0 +1,121 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" +) + +// CreateAuthConfigRequest represents the body for a CreateAuthConfig request. +type CreateAuthConfigRequest struct { + ASN1DN *authority.ASN1DN `json:"asn1dn,omitempty"` + Claims *config.Claims `json:"claims,omitempty"` + DisableIssuedAtCheck bool `json:"disableIssuedAtCheck,omitempty"` + Backdate string `json:"backdate,omitempty"` +} + +// Validate validates a CreateAuthConfig request body. +func (car *CreateAuthConfigRequest) Validate() error { + return nil +} + +// UpdateAuthConfigRequest represents the body for a UpdateAuthConfig request. +type UpdateAuthConfigRequest struct { + ASN1DN *authority.ASN1DN `json:"asn1dn"` + Claims *config.Claims `json:"claims"` + DisableIssuedAtCheck bool `json:"disableIssuedAtCheck,omitempty"` + Backdate string `json:"backdate,omitempty"` +} + +// Validate validates a new-admin request body. +func (uar *UpdateAuthConfigRequest) Validate() error { + return nil +} + +// GetAuthConfig returns the requested admin, or an error. +func (h *Handler) GetAuthConfig(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "id") + + ac, err := h.db.GetAuthConfig(ctx, id) + if err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, ac) +} + +// CreateAuthConfig creates a new admin. +func (h *Handler) CreateAuthConfig(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var body CreateAuthConfigRequest + if err := ReadJSON(r.Body, &body); err != nil { + api.WriteError(w, err) + return + } + if err := body.Validate(); err != nil { + api.WriteError(w, err) + } + + ac := config.AuthConfig{ + Status: config.StatusActive, + DisableIssuedAtCheck: body.DisableIssuedAtCheck, + Backdate: "1m", + } + if body.ASN1DN != nil { + ac.ASN1DN = body.ASN1DN + } + if body.Claims != nil { + ac.Claims = body.Claims + } + if body.Backdate != "" { + ac.Backdate = body.Backdate + } + if err := h.db.CreateAuthConfig(ctx, ac); err != nil { + api.WriteError(w, err) + return + } + api.JSONStatus(w, ac, http.StatusCreated) +} + +// UpdateAuthConfig updates an existing AuthConfig. +func (h *Handler) UpdateAuthConfig(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "id") + + var body UpdateAuthConfigRequest + if err := ReadJSON(r.Body, &body); err != nil { + api.WriteError(w, err) + return + } + if err := body.Validate(); err != nil { + api.WriteError(w, err) + return + } + if ac, err := h.db.GetAuthConfig(ctx, id); err != nil { + api.WriteError(w, err) + return + } + + ac.DisableIssuedAtCheck = body.DisableIssuedAtCheck + ac.Status = body.Status + if body.ASN1DN != nil { + ac.ASN1DN = body.ASN1DN + } + if body.Claims != nil { + ac.Claims = body.Claims + } + if body.Backdate != "" { + ac.Backdate = body.Backdate + } + + if err := h.db.UpdateAuthConfig(ctx, ac); err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, ac) +} diff --git a/authority/mgmt/api/handler.go b/authority/mgmt/api/handler.go new file mode 100644 index 00000000..f30544c5 --- /dev/null +++ b/authority/mgmt/api/handler.go @@ -0,0 +1,50 @@ +package api + +import ( + "time" + + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/authority/config" +) + +// Clock that returns time in UTC rounded to seconds. +type Clock struct{} + +// Now returns the UTC time rounded to seconds. +func (c *Clock) Now() time.Time { + return time.Now().UTC().Truncate(time.Second) +} + +var clock Clock + +// Handler is the ACME API request handler. +type Handler struct { + db config.DB +} + +// NewHandler returns a new Authority Config Handler. +func NewHandler(db config.DB) api.RouterHandler { + return &Handler{ + db: ops.DB, + } +} + +// Route traffic and implement the Router interface. +func (h *Handler) Route(r api.Router) { + // Provisioners + r.MethodFunc("GET", "/provisioner/{id}", h.GetProvisioner) + r.MethodFunc("GET", "/provisioners", h.GetProvisioners) + r.MethodFunc("POST", "/provisioner", h.CreateProvisioner) + r.MethodFunc("PUT", "/provsiioner/{id}", h.UpdateProvisioner) + + // Admins + r.MethodFunc("GET", "/admin/{id}", h.GetAdmin) + r.MethodFunc("GET", "/admins", h.GetAdmins) + r.MethodFunc("POST", "/admin", h.CreateAdmin) + r.MethodFunc("PUT", "/admin/{id}", h.UpdateAdmin) + + // AuthConfig + r.MethodFunc("GET", "/authconfig/{id}", h.GetAuthConfig) + r.MethodFunc("POST", "/authconfig", h.CreateAuthConfig) + r.MethodFunc("PUT", "/authconfig/{id}", h.UpdateAuthConfig) +} diff --git a/authority/mgmt/api/provisioner.go b/authority/mgmt/api/provisioner.go new file mode 100644 index 00000000..76bbe7cf --- /dev/null +++ b/authority/mgmt/api/provisioner.go @@ -0,0 +1,122 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/authority/config" +) + +// CreateProvisionerRequest represents the body for a CreateProvisioner request. +type CreateProvisionerRequest struct { + Type string `json:"type"` + Name string `json:"name"` + Claims *config.Claims `json:"claims"` + Details interface{} `json:"details"` + X509Template string `json:"x509Template"` + SSHTemplate string `json:"sshTemplate"` +} + +// Validate validates a new-provisioner request body. +func (car *CreateProvisionerRequest) Validate() error { + return nil +} + +// UpdateProvisionerRequest represents the body for a UpdateProvisioner request. +type UpdateProvisionerRequest struct { + Claims *config.Claims `json:"claims"` + Details interface{} `json:"details"` + X509Template string `json:"x509Template"` + SSHTemplate string `json:"sshTemplate"` +} + +// Validate validates a new-provisioner request body. +func (uar *UpdateProvisionerRequest) Validate() error { + return nil +} + +// GetProvisioner returns the requested provisioner, or an error. +func (h *Handler) GetProvisioner(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "id") + + prov, err := h.db.GetProvisioner(ctx, id) + if err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, prov) +} + +// GetProvisioners returns all provisioners associated with the authority. +func (h *Handler) GetProvisioners(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + provs, err := h.db.GetProvisioners(ctx) + if err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, provs) +} + +// CreateProvisioner creates a new prov. +func (h *Handler) CreateProvisioner(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var body CreateProvisionerRequest + if err := ReadJSON(r.Body, &body); err != nil { + api.WriteError(w, err) + return + } + if err := body.Validate(); err != nil { + api.WriteError(w, err) + } + + prov := &config.Provisioner{ + Type: body.Type, + Name: body.Name, + Claims: body.Claims, + Details: body.Details, + X509Template: body.X509Template, + SSHTemplate: body.SSHTemplate, + } + if err := h.db.CreateProvisioner(ctx, prov); err != nil { + api.WriteError(w, err) + return + } + api.JSONStatus(w, prov, http.StatusCreated) +} + +// UpdateProvisioner updates an existing prov. +func (h *Handler) UpdateProvisioner(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "id") + + var body UpdateProvisionerRequest + if err := ReadJSON(r.Body, &body); err != nil { + api.WriteError(w, err) + return + } + if err := body.Validate(); err != nil { + api.WriteError(w, err) + return + } + if prov, err := h.db.GetProvisioner(ctx, id); err != nil { + api.WriteError(w, err) + return + } + + prov.Claims = body.Claims + prov.Details = body.Provisioner + prov.X509Template = body.X509Template + prov.SSHTemplate = body.SSHTemplate + prov.Status = body.Status + + if err := h.db.UpdateProvisioner(ctx, prov); err != nil { + api.WriteError(w, err) + return + } + api.JSON(w, prov) +} diff --git a/authority/mgmt/config.go b/authority/mgmt/config.go new file mode 100644 index 00000000..b5bebceb --- /dev/null +++ b/authority/mgmt/config.go @@ -0,0 +1,357 @@ +package mgmt + +import ( + "github.com/smallstep/certificates/authority/config" + authority "github.com/smallstep/certificates/authority/config" +) + +const ( + DefaultAuthorityID = "00000000-0000-0000-0000-000000000000" +) + +// StatusType is the type for status. +type StatusType int + +const ( + // StatusActive active + StatusActive StatusType = iota + // StatusDeleted deleted + StatusDeleted +) + +type Claims struct { + *X509Claims `json:"x509Claims"` + *SSHClaims `json:"sshClaims"` + DisableRenewal *bool `json:"disableRenewal"` +} + +type X509Claims struct { + Durations *Durations `json:"durations"` +} + +type SSHClaims struct { + UserDuration *Durations `json:"userDurations"` + HostDuration *Durations `json:"hostDuration"` +} + +type Durations struct { + Min string `json:"min"` + Max string `json:"max"` + Default string `json:"default"` +} + +// Admin type. +type Admin struct { + ID string `json:"-"` + AuthorityID string `json:"-"` + Name string `json:"name"` + Provisioner string `json:"provisioner"` + IsSuperAdmin bool `json:"isSuperAdmin"` + Status StatusType `json:"status"` +} + +// Provisioner type. +type Provisioner struct { + ID string `json:"-"` + AuthorityID string `json:"-"` + Type string `json:"type"` + Name string `json:"name"` + Claims *Claims `json:"claims"` + Details interface{} `json:"details"` + X509Template string `json:"x509Template"` + SSHTemplate string `json:"sshTemplate"` + Status StatusType `json:"status"` +} + +// AuthConfig represents the Authority Configuration. +type AuthConfig struct { + //*cas.Options `json:"cas"` + ID string `json:"id"` + ASN1DN *config.ASN1DN `json:"template,omitempty"` + Provisioners []*Provisioner `json:"-"` + Claims *Claims `json:"claims,omitempty"` + DisableIssuedAtCheck bool `json:"disableIssuedAtCheck,omitempty"` + Backdate string `json:"backdate,omitempty"` + Status StatusType `json:"status,omitempty"` +} + +func (ac *AuthConfig) ToCertificates() (*config.AuthConfig, error) { + return &authority.AuthConfig{}, nil +} + +/* +// ToCertificates converts the landlord provisioner type to the open source +// provisioner type. +func (p *Provisioner) ToCertificates(ctx context.Context, db database.DB) (provisioner.Interface, error) { + claims, err := p.Claims.ToCertificates() + if err != nil { + return nil, err + } + + details := p.Details.GetData() + if details == nil { + return nil, fmt.Errorf("provisioner does not have any details") + } + + options, err := p.getOptions(ctx, db) + if err != nil { + return nil, err + } + + switch d := details.(type) { + case *ProvisionerDetails_JWK: + k := d.JWK.GetKey() + jwk := new(jose.JSONWebKey) + if err := json.Unmarshal(k.Key.Public, &jwk); err != nil { + return nil, err + } + return &provisioner.JWK{ + Type: p.Type.String(), + Name: p.Name, + Key: jwk, + EncryptedKey: string(k.Key.Private), + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_OIDC: + cfg := d.OIDC + return &provisioner.OIDC{ + Type: p.Type.String(), + Name: p.Name, + TenantID: cfg.TenantId, + ClientID: cfg.ClientId, + ClientSecret: cfg.ClientSecret, + ConfigurationEndpoint: cfg.ConfigurationEndpoint, + Admins: cfg.Admins, + Domains: cfg.Domains, + Groups: cfg.Groups, + ListenAddress: cfg.ListenAddress, + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_GCP: + cfg := d.GCP + return &provisioner.GCP{ + Type: p.Type.String(), + Name: p.Name, + ServiceAccounts: cfg.ServiceAccounts, + ProjectIDs: cfg.ProjectIds, + DisableCustomSANs: cfg.DisableCustomSans, + DisableTrustOnFirstUse: cfg.DisableTrustOnFirstUse, + InstanceAge: durationValue(cfg.InstanceAge), + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_AWS: + cfg := d.AWS + return &provisioner.AWS{ + Type: p.Type.String(), + Name: p.Name, + Accounts: cfg.Accounts, + DisableCustomSANs: cfg.DisableCustomSans, + DisableTrustOnFirstUse: cfg.DisableTrustOnFirstUse, + InstanceAge: durationValue(cfg.InstanceAge), + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_Azure: + cfg := d.Azure + return &provisioner.Azure{ + Type: p.Type.String(), + Name: p.Name, + TenantID: cfg.TenantId, + ResourceGroups: cfg.ResourceGroups, + Audience: cfg.Audience, + DisableCustomSANs: cfg.DisableCustomSans, + DisableTrustOnFirstUse: cfg.DisableTrustOnFirstUse, + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_X5C: + var roots []byte + for i, k := range d.X5C.GetRoots() { + if b := k.GetKey().GetPublic(); b != nil { + if i > 0 { + roots = append(roots, '\n') + } + roots = append(roots, b...) + } + } + return &provisioner.X5C{ + Type: p.Type.String(), + Name: p.Name, + Roots: roots, + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_K8SSA: + var publicKeys []byte + for i, k := range d.K8SSA.GetPublicKeys() { + if b := k.GetKey().GetPublic(); b != nil { + if i > 0 { + publicKeys = append(publicKeys, '\n') + } + publicKeys = append(publicKeys, k.Key.Public...) + } + } + return &provisioner.K8sSA{ + Type: p.Type.String(), + Name: p.Name, + PubKeys: publicKeys, + Claims: claims, + Options: options, + }, nil + case *ProvisionerDetails_SSHPOP: + return &provisioner.SSHPOP{ + Type: p.Type.String(), + Name: p.Name, + Claims: claims, + }, nil + case *ProvisionerDetails_ACME: + cfg := d.ACME + return &provisioner.ACME{ + Type: p.Type.String(), + Name: p.Name, + ForceCN: cfg.ForceCn, + Claims: claims, + Options: options, + }, nil + default: + return nil, fmt.Errorf("provisioner %s not implemented", p.Type.String()) + } +} + +// ToCertificates converts the landlord provisioner claims type to the open source +// (step-ca) claims type. +func (c *Claims) ToCertificates() (*provisioner.Claims, error) { + x509, ssh := c.GetX509(), c.GetSsh() + x509Durations := x509.GetDurations() + hostDurations := ssh.GetHostDurations() + userDurations := ssh.GetUserDurations() + enableSSHCA := ssh.GetEnabled() + return &provisioner.Claims{ + MinTLSDur: durationPtr(x509Durations.GetMin()), + MaxTLSDur: durationPtr(x509Durations.GetMax()), + DefaultTLSDur: durationPtr(x509Durations.GetDefault()), + DisableRenewal: &c.DisableRenewal, + MinUserSSHDur: durationPtr(userDurations.GetMin()), + MaxUserSSHDur: durationPtr(userDurations.GetMax()), + DefaultUserSSHDur: durationPtr(userDurations.GetDefault()), + MinHostSSHDur: durationPtr(hostDurations.GetMin()), + MaxHostSSHDur: durationPtr(hostDurations.GetMax()), + DefaultHostSSHDur: durationPtr(hostDurations.GetDefault()), + EnableSSHCA: &enableSSHCA, + }, nil +} + +func durationPtr(d *duration.Duration) *provisioner.Duration { + if d == nil { + return nil + } + return &provisioner.Duration{ + Duration: time.Duration(d.Seconds)*time.Second + time.Duration(d.Nanos)*time.Nanosecond, + } +} + +func durationValue(d *duration.Duration) provisioner.Duration { + if d == nil { + return provisioner.Duration{} + } + return provisioner.Duration{ + Duration: time.Duration(d.Seconds)*time.Second + time.Duration(d.Nanos)*time.Nanosecond, + } +} + +func marshalDetails(d *ProvisionerDetails) (sql.NullString, error) { + b, err := json.Marshal(d.GetData()) + if err != nil { + return sql.NullString{}, nil + } + return sql.NullString{ + String: string(b), + Valid: len(b) > 0, + }, nil +} + +func unmarshalDetails(ctx context.Context, db database.DB, typ ProvisionerType, s sql.NullString) (*ProvisionerDetails, error) { + if !s.Valid { + return nil, nil + } + var v isProvisionerDetails_Data + switch typ { + case ProvisionerType_JWK: + p := new(ProvisionerDetails_JWK) + if err := json.Unmarshal([]byte(s.String), p); err != nil { + return nil, err + } + if p.JWK.Key.Key == nil { + key, err := LoadKey(ctx, db, p.JWK.Key.Id.Id) + if err != nil { + return nil, err + } + p.JWK.Key = key + } + return &ProvisionerDetails{Data: p}, nil + case ProvisionerType_OIDC: + v = new(ProvisionerDetails_OIDC) + case ProvisionerType_GCP: + v = new(ProvisionerDetails_GCP) + case ProvisionerType_AWS: + v = new(ProvisionerDetails_AWS) + case ProvisionerType_AZURE: + v = new(ProvisionerDetails_Azure) + case ProvisionerType_ACME: + v = new(ProvisionerDetails_ACME) + case ProvisionerType_X5C: + p := new(ProvisionerDetails_X5C) + if err := json.Unmarshal([]byte(s.String), p); err != nil { + return nil, err + } + for _, k := range p.X5C.GetRoots() { + if err := k.Select(ctx, db, k.Id.Id); err != nil { + return nil, err + } + } + return &ProvisionerDetails{Data: p}, nil + case ProvisionerType_K8SSA: + p := new(ProvisionerDetails_K8SSA) + if err := json.Unmarshal([]byte(s.String), p); err != nil { + return nil, err + } + for _, k := range p.K8SSA.GetPublicKeys() { + if err := k.Select(ctx, db, k.Id.Id); err != nil { + return nil, err + } + } + return &ProvisionerDetails{Data: p}, nil + case ProvisionerType_SSHPOP: + v = new(ProvisionerDetails_SSHPOP) + default: + return nil, fmt.Errorf("unsupported provisioner type %s", typ) + } + + if err := json.Unmarshal([]byte(s.String), v); err != nil { + return nil, err + } + return &ProvisionerDetails{Data: v}, nil +} + +func marshalClaims(c *Claims) (sql.NullString, error) { + b, err := json.Marshal(c) + if err != nil { + return sql.NullString{}, nil + } + return sql.NullString{ + String: string(b), + Valid: len(b) > 0, + }, nil +} + +func unmarshalClaims(s sql.NullString) (*Claims, error) { + if !s.Valid { + return nil, nil + } + v := new(Claims) + return v, json.Unmarshal([]byte(s.String), v) +} +*/ diff --git a/authority/mgmt/db.go b/authority/mgmt/db.go new file mode 100644 index 00000000..99228a4d --- /dev/null +++ b/authority/mgmt/db.go @@ -0,0 +1,149 @@ +package mgmt + +import ( + "context" + + "github.com/pkg/errors" +) + +// ErrNotFound is an error that should be used by the authority.DB interface to +// indicate that an entity does not exist. +var ErrNotFound = errors.New("not found") + +// DB is the DB interface expected by the step-ca ACME API. +type DB interface { + CreateProvisioner(ctx context.Context, prov *Provisioner) error + GetProvisioner(ctx context.Context, id string) (*Provisioner, error) + GetProvisioners(ctx context.Context) ([]*Provisioner, error) + UpdateProvisioner(ctx context.Context, prov *Provisioner) error + + CreateAdmin(ctx context.Context, admin *Admin) error + GetAdmin(ctx context.Context, id string) error + GetAdmins(ctx context.Context) ([]*Admin, error) + UpdateAdmin(ctx context.Context, admin *Admin) error + + CreateAuthConfig(ctx context.Context, ac *AuthConfig) error + GetAuthConfig(ctx context.Context, id string) (*AuthConfig, error) + UpdateAuthConfig(ctx context.Context, ac *AuthConfig) error +} + +// MockDB is an implementation of the DB interface that should only be used as +// a mock in tests. +type MockDB struct { + MockCreateProvisioner func(ctx context.Context, prov *Provisioner) error + MockGetProvisioner func(ctx context.Context, id string) (*Provisioner, error) + MockGetProvisioners func(ctx context.Context) ([]*Provisioner, error) + MockUpdateProvisioner func(ctx context.Context, prov *Provisioner) error + + MockCreateAdmin func(ctx context.Context, adm *Admin) error + MockGetAdmin func(ctx context.Context, id string) (*Admin, error) + MockGetAdmins func(ctx context.Context) ([]*Admin, error) + MockUpdateAdmin func(ctx context.Context, adm *Admin) error + + MockCreateAuthConfig func(ctx context.Context, ac *AuthConfig) error + MockGetAuthConfig func(ctx context.Context, id string) (*AuthConfig, error) + MockUpdateAuthConfig func(ctx context.Context, ac *AuthConfig) error + + MockError error + MockRet1 interface{} +} + +// CreateProvisioner mock. +func (m *MockDB) CreateProvisioner(ctx context.Context, prov *Provisioner) error { + if m.MockCreateProvisioner != nil { + return m.MockCreateProvisioner(ctx, prov) + } else if m.MockError != nil { + return m.MockError + } + return m.MockError +} + +// GetProvisioner mock. +func (m *MockDB) GetProvisioner(ctx context.Context, id string) (*Provisioner, error) { + if m.MockGetProvisioner != nil { + return m.MockGetProvisioner(ctx, id) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.(*Provisioner), m.MockError +} + +// GetProvisioners mock +func (m *MockDB) GetProvisioners(ctx context.Context) ([]*Provisioner, error) { + if m.MockGetProvisioners != nil { + return m.MockGetProvisioners(ctx) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.([]*Provisioner), m.MockError +} + +// UpdateProvisioner mock +func (m *MockDB) UpdateProvisioner(ctx context.Context, prov *Provisioner) error { + if m.MockUpdateProvisioner != nil { + return m.MockUpdateProvisioner(ctx, prov) + } + return m.MockError +} + +// CreateAdmin mock +func (m *MockDB) CreateAdmin(ctx context.Context, admin *Admin) error { + if m.MockCreateAdmin != nil { + return m.MockCreateAdmin(ctx, admin) + } + return m.MockError +} + +// GetAdmin mock. +func (m *MockDB) GetAdmin(ctx context.Context, id string) (*Admin, error) { + if m.MockGetAdmin != nil { + return m.MockGetAdmin(ctx, id) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.(*Admin), m.MockError +} + +// GetAdmins mock +func (m *MockDB) GetAdmins(ctx context.Context) ([]*Admin, error) { + if m.MockGetAdmins != nil { + return m.MockGetAdmins(ctx) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.([]*Admin), m.MockError +} + +// UpdateAdmin mock +func (m *MockDB) UpdateAdmin(ctx context.Context, adm *Admin) error { + if m.UpdateAdmin != nil { + return m.MockUpdateAdmin(ctx, adm) + } + return m.MockError +} + +// CreateAuthConfig mock +func (m *MockDB) CreateAuthConfig(ctx context.Context, admin *AuthConfig) error { + if m.MockCreateAuthConfig != nil { + return m.MockCreateAuthConfig(ctx, admin) + } + return m.MockError +} + +// GetAuthConfig mock. +func (m *MockDB) GetAuthConfig(ctx context.Context, id string) (*AuthConfig, error) { + if m.MockGetAuthConfig != nil { + return m.MockGetAuthConfig(ctx, id) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.(*AuthConfig), m.MockError +} + +// UpdateAuthConfig mock +func (m *MockDB) UpdateAuthConfig(ctx context.Context, adm *AuthConfig) error { + if m.UpdateAuthConfig != nil { + return m.MockUpdateAuthConfig(ctx, adm) + } + return m.MockError +} diff --git a/authority/mgmt/db/nosql/admin.go b/authority/mgmt/db/nosql/admin.go new file mode 100644 index 00000000..8b33110c --- /dev/null +++ b/authority/mgmt/db/nosql/admin.go @@ -0,0 +1,163 @@ +package nosql + +import ( + "context" + "encoding/json" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/acme" + "github.com/smallstep/certificates/authority/mgmt" + "github.com/smallstep/nosql" +) + +// dbAdmin is the database representation of the Admin type. +type dbAdmin struct { + ID string `json:"id"` + AuthorityID string `json:"authorityID"` + Name string `json:"name"` + Provisioner string `json:"provisioner"` + IsSuperAdmin bool `json:"isSuperAdmin"` + CreatedAt time.Time `json:"createdAt"` + DeletedAt time.Time `json:"deletedAt"` +} + +func (dbp *dbAdmin) clone() *dbAdmin { + u := *dbp + return &u +} + +func (db *DB) getDBAdminBytes(ctx context.Context, id string) ([]byte, error) { + data, err := db.db.Get(authorityAdminsTable, []byte(id)) + if nosql.IsErrNotFound(err) { + return nil, mgmt.NewError(mgmt.ErrorNotFoundType, "admin %s not found", id) + } else if err != nil { + return nil, errors.Wrapf(err, "error loading admin %s", id) + } + return data, nil +} + +func (db *DB) getDBAdmin(ctx context.Context, id string) (*dbAdmin, error) { + data, err := db.getDBAdminBytes(ctx, id) + if err != nil { + return nil, err + } + dba, err := unmarshalDBAdmin(data, id) + if err != nil { + return nil, err + } + if dba.AuthorityID != db.authorityID { + return nil, mgmt.NewError(mgmt.ErrorAuthorityMismatchType, + "admin %s is not owned by authority %s", dba.ID, db.authorityID) + } + return dba, nil +} + +// GetAdmin retrieves and unmarshals a admin from the database. +func (db *DB) GetAdmin(ctx context.Context, id string) (*mgmt.Admin, error) { + data, err := db.getDBAdminBytes(ctx, id) + if err != nil { + return nil, err + } + adm, err := unmarshalAdmin(data, id) + if err != nil { + return nil, err + } + if adm.Status == mgmt.StatusDeleted { + return nil, mgmt.NewError(mgmt.ErrorDeletedType, "admin %s is deleted") + } + if adm.AuthorityID != db.authorityID { + return nil, mgmt.NewError(mgmt.ErrorAuthorityMismatchType, + "admin %s is not owned by authority %s", adm.ID, db.authorityID) + } + return adm, nil +} + +func unmarshalDBAdmin(data []byte, id string) (*dbAdmin, error) { + var dba = new(dbAdmin) + if err := json.Unmarshal(data, dba); err != nil { + return nil, errors.Wrapf(err, "error unmarshaling admin %s into dbAdmin", id) + } + return dba, nil +} + +func unmarshalAdmin(data []byte, id string) (*mgmt.Admin, error) { + var dba = new(dbAdmin) + if err := json.Unmarshal(data, dba); err != nil { + return nil, errors.Wrapf(err, "error unmarshaling admin %s into dbAdmin", id) + } + adm := &mgmt.Admin{ + ID: dba.ID, + Name: dba.Name, + Provisioner: dba.Provisioner, + IsSuperAdmin: dba.IsSuperAdmin, + } + if !dba.DeletedAt.IsZero() { + adm.Status = mgmt.StatusDeleted + } + return adm, nil +} + +// GetAdmins retrieves and unmarshals all active (not deleted) admins +// from the database. +// TODO should we be paginating? +func (db *DB) GetAdmins(ctx context.Context, az *acme.Authorization) ([]*mgmt.Admin, error) { + dbEntries, err := db.db.List(authorityAdminsTable) + if err != nil { + return nil, errors.Wrap(err, "error loading admins") + } + var admins []*mgmt.Admin + for _, entry := range dbEntries { + adm, err := unmarshalAdmin(entry.Value, string(entry.Key)) + if err != nil { + return nil, err + } + if adm.Status == mgmt.StatusDeleted { + continue + } + if adm.AuthorityID != db.authorityID { + continue + } + admins = append(admins, adm) + } + return admins, nil +} + +// CreateAdmin stores a new admin to the database. +func (db *DB) CreateAdmin(ctx context.Context, adm *mgmt.Admin) error { + var err error + adm.ID, err = randID() + if err != nil { + return errors.Wrap(err, "error generating random id for admin") + } + + dba := &dbAdmin{ + ID: adm.ID, + AuthorityID: db.authorityID, + Name: adm.Name, + Provisioner: adm.Provisioner, + IsSuperAdmin: adm.IsSuperAdmin, + CreatedAt: clock.Now(), + } + + return db.save(ctx, dba.ID, dba, nil, "admin", authorityAdminsTable) +} + +// UpdateAdmin saves an updated admin to the database. +func (db *DB) UpdateAdmin(ctx context.Context, adm *mgmt.Admin) error { + old, err := db.getDBAdmin(ctx, adm.ID) + if err != nil { + return err + } + + nu := old.clone() + + // If the admin was active but is now deleted ... + if old.DeletedAt.IsZero() && adm.Status == mgmt.StatusDeleted { + nu.DeletedAt = clock.Now() + } + nu.Provisioner = adm.Provisioner + nu.IsSuperAdmin = adm.IsSuperAdmin + + return db.save(ctx, old.ID, nu, old, "admin", authorityAdminsTable) +} diff --git a/authority/mgmt/db/nosql/authConfig.go b/authority/mgmt/db/nosql/authConfig.go new file mode 100644 index 00000000..6fe1266b --- /dev/null +++ b/authority/mgmt/db/nosql/authConfig.go @@ -0,0 +1,113 @@ +package nosql + +import ( + "context" + "encoding/json" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/mgmt" + "github.com/smallstep/nosql" +) + +type dbAuthConfig struct { + ID string `json:"id"` + ASN1DN *config.ASN1DN `json:"asn1dn"` + Claims *mgmt.Claims `json:"claims"` + DisableIssuedAtCheck bool `json:"disableIssuedAtCheck,omitempty"` + Backdate string `json:"backdate,omitempty"` + CreatedAt time.Time `json:"createdAt"` + DeletedAt time.Time `json:"deletedAt"` +} + +func (dbp *dbAuthConfig) clone() *dbAuthConfig { + u := *dbp + return &u +} + +func (db *DB) getDBAuthConfigBytes(ctx context.Context, id string) ([]byte, error) { + data, err := db.db.Get(authorityConfigsTable, []byte(id)) + if nosql.IsErrNotFound(err) { + return nil, mgmt.NewError(mgmt.ErrorNotFoundType, "authConfig %s not found", id) + } else if err != nil { + return nil, errors.Wrapf(err, "error loading authConfig %s", id) + } + return data, nil +} + +func (db *DB) getDBAuthConfig(ctx context.Context, id string) (*dbAuthConfig, error) { + data, err := db.getDBAuthConfigBytes(ctx, id) + if err != nil { + return nil, err + } + + var dba = new(dbAuthConfig) + if err = json.Unmarshal(data, dba); err != nil { + return nil, errors.Wrapf(err, "error unmarshaling authority %s into dbAuthConfig", id) + } + + return dba, nil +} + +// GetAuthConfig retrieves an AuthConfig configuration from the DB. +func (db *DB) GetAuthConfig(ctx context.Context, id string) (*mgmt.AuthConfig, error) { + dba, err := db.getDBAuthConfig(ctx, id) + if err != nil { + return nil, err + } + + provs, err := db.GetProvisioners(ctx) + if err != nil { + return nil, err + } + + return &mgmt.AuthConfig{ + ID: dba.ID, + Provisioners: provs, + ASN1DN: dba.ASN1DN, + Backdate: dba.Backdate, + Claims: dba.Claims, + DisableIssuedAtCheck: dba.DisableIssuedAtCheck, + }, nil +} + +// CreateAuthConfig stores a new provisioner to the database. +func (db *DB) CreateAuthConfig(ctx context.Context, ac *mgmt.AuthConfig) error { + var err error + ac.ID, err = randID() + if err != nil { + return errors.Wrap(err, "error generating random id for provisioner") + } + + dba := &dbAuthConfig{ + ID: ac.ID, + ASN1DN: ac.ASN1DN, + Claims: ac.Claims, + DisableIssuedAtCheck: ac.DisableIssuedAtCheck, + Backdate: ac.Backdate, + CreatedAt: clock.Now(), + } + + return db.save(ctx, dba.ID, dba, nil, "authConfig", authorityConfigsTable) +} + +// UpdateAuthConfig saves an updated provisioner to the database. +func (db *DB) UpdateAuthConfig(ctx context.Context, ac *mgmt.AuthConfig) error { + old, err := db.getDBAuthConfig(ctx, ac.ID) + if err != nil { + return err + } + + nu := old.clone() + + // If the authority was active but is now deleted ... + if old.DeletedAt.IsZero() && ac.Status == mgmt.StatusDeleted { + nu.DeletedAt = clock.Now() + } + nu.Claims = ac.Claims + nu.DisableIssuedAtCheck = ac.DisableIssuedAtCheck + nu.Backdate = ac.Backdate + + return db.save(ctx, old.ID, nu, old, "authConfig", authorityProvisionersTable) +} diff --git a/authority/mgmt/db/nosql/nosql.go b/authority/mgmt/db/nosql/nosql.go new file mode 100644 index 00000000..d71b2804 --- /dev/null +++ b/authority/mgmt/db/nosql/nosql.go @@ -0,0 +1,91 @@ +package nosql + +import ( + "context" + "encoding/json" + "time" + + "github.com/pkg/errors" + nosqlDB "github.com/smallstep/nosql/database" + "go.step.sm/crypto/randutil" +) + +var ( + authorityAdminsTable = []byte("authority_admins") + authorityConfigsTable = []byte("authority_configs") + authorityProvisionersTable = []byte("authority_provisioners") +) + +// DB is a struct that implements the AcmeDB interface. +type DB struct { + db nosqlDB.DB + authorityID string +} + +// New configures and returns a new Authority DB backend implemented using a nosql DB. +func New(db nosqlDB.DB, authorityID string) (*DB, error) { + tables := [][]byte{authorityAdminsTable, authorityConfigsTable, authorityProvisionersTable} + for _, b := range tables { + if err := db.CreateTable(b); err != nil { + return nil, errors.Wrapf(err, "error creating table %s", + string(b)) + } + } + return &DB{db, authorityID}, nil +} + +// save writes the new data to the database, overwriting the old data if it +// existed. +func (db *DB) save(ctx context.Context, id string, nu interface{}, old interface{}, typ string, table []byte) error { + var ( + err error + newB []byte + ) + if nu == nil { + newB = nil + } else { + newB, err = json.Marshal(nu) + if err != nil { + return errors.Wrapf(err, "error marshaling authority type: %s, value: %v", typ, nu) + } + } + var oldB []byte + if old == nil { + oldB = nil + } else { + oldB, err = json.Marshal(old) + if err != nil { + return errors.Wrapf(err, "error marshaling acme type: %s, value: %v", typ, old) + } + } + + _, swapped, err := db.db.CmpAndSwap(table, []byte(id), oldB, newB) + switch { + case err != nil: + return errors.Wrapf(err, "error saving authority %s", typ) + case !swapped: + return errors.Errorf("error saving authority %s; changed since last read", typ) + default: + return nil + } +} + +var idLen = 32 + +func randID() (val string, err error) { + val, err = randutil.Alphanumeric(idLen) + if err != nil { + return "", errors.Wrap(err, "error generating random alphanumeric ID") + } + return val, nil +} + +// Clock that returns time in UTC rounded to seconds. +type Clock struct{} + +// Now returns the UTC time rounded to seconds. +func (c *Clock) Now() time.Time { + return time.Now().UTC().Truncate(time.Second) +} + +var clock = new(Clock) diff --git a/authority/mgmt/db/nosql/provisioner.go b/authority/mgmt/db/nosql/provisioner.go new file mode 100644 index 00000000..93dcc47b --- /dev/null +++ b/authority/mgmt/db/nosql/provisioner.go @@ -0,0 +1,174 @@ +package nosql + +import ( + "context" + "encoding/json" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/mgmt" + "github.com/smallstep/nosql" +) + +// dbProvisioner is the database representation of a Provisioner type. +type dbProvisioner struct { + ID string `json:"id"` + AuthorityID string `json:"authorityID"` + Type string `json:"type"` + Name string `json:"name"` + Claims *mgmt.Claims `json:"claims"` + Details interface{} `json:"details"` + X509Template string `json:"x509Template"` + SSHTemplate string `json:"sshTemplate"` + CreatedAt time.Time `json:"createdAt"` + DeletedAt time.Time `json:"deletedAt"` +} + +func (dbp *dbProvisioner) clone() *dbProvisioner { + u := *dbp + return &u +} + +func (db *DB) getDBProvisionerBytes(ctx context.Context, id string) ([]byte, error) { + data, err := db.db.Get(authorityProvisionersTable, []byte(id)) + if nosql.IsErrNotFound(err) { + return nil, mgmt.NewError(mgmt.ErrorNotFoundType, "provisioner %s not found", id) + } else if err != nil { + return nil, errors.Wrapf(err, "error loading provisioner %s", id) + } + return data, nil +} + +func (db *DB) getDBProvisioner(ctx context.Context, id string) (*dbProvisioner, error) { + data, err := db.getDBProvisionerBytes(ctx, id) + if err != nil { + return nil, err + } + dbp, err := unmarshalDBProvisioner(data, id) + if err != nil { + return nil, err + } + if dbp.AuthorityID != db.authorityID { + return nil, mgmt.NewError(mgmt.ErrorAuthorityMismatchType, + "provisioner %s is not owned by authority %s", dbp.ID, db.authorityID) + } + return dbp, nil +} + +// GetProvisioner retrieves and unmarshals a provisioner from the database. +func (db *DB) GetProvisioner(ctx context.Context, id string) (*mgmt.Provisioner, error) { + data, err := db.getDBProvisionerBytes(ctx, id) + if err != nil { + return nil, err + } + + prov, err := unmarshalProvisioner(data, id) + if err != nil { + return nil, err + } + if prov.Status == mgmt.StatusDeleted { + return nil, mgmt.NewError(mgmt.ErrorDeletedType, "provisioner %s is deleted", prov.ID) + } + if prov.AuthorityID != db.authorityID { + return nil, mgmt.NewError(mgmt.ErrorAuthorityMismatchType, + "provisioner %s is not owned by authority %s", prov.ID, db.authorityID) + } + return prov, nil +} + +func unmarshalDBProvisioner(data []byte, id string) (*dbProvisioner, error) { + var dbp = new(dbProvisioner) + if err := json.Unmarshal(data, dbp); err != nil { + return nil, errors.Wrapf(err, "error unmarshaling provisioner %s into dbProvisioner", id) + } + return dbp, nil +} + +func unmarshalProvisioner(data []byte, id string) (*mgmt.Provisioner, error) { + dbp, err := unmarshalDBProvisioner(data, id) + if err != nil { + return nil, err + } + + prov := &mgmt.Provisioner{ + ID: dbp.ID, + Type: dbp.Type, + Name: dbp.Name, + Claims: dbp.Claims, + X509Template: dbp.X509Template, + SSHTemplate: dbp.SSHTemplate, + } + if !dbp.DeletedAt.IsZero() { + prov.Status = mgmt.StatusDeleted + } + return prov, nil +} + +// GetProvisioners retrieves and unmarshals all active (not deleted) provisioners +// from the database. +// TODO should we be paginating? +func (db *DB) GetProvisioners(ctx context.Context) ([]*mgmt.Provisioner, error) { + dbEntries, err := db.db.List(authorityProvisionersTable) + if err != nil { + return nil, errors.Wrap(err, "error loading provisioners") + } + var provs []*mgmt.Provisioner + for _, entry := range dbEntries { + prov, err := unmarshalProvisioner(entry.Value, string(entry.Key)) + if err != nil { + return nil, err + } + if prov.Status == mgmt.StatusDeleted { + continue + } + if prov.AuthorityID != db.authorityID { + continue + } + provs = append(provs, prov) + } + return provs, nil +} + +// CreateProvisioner stores a new provisioner to the database. +func (db *DB) CreateProvisioner(ctx context.Context, prov *mgmt.Provisioner) error { + var err error + prov.ID, err = randID() + if err != nil { + return errors.Wrap(err, "error generating random id for provisioner") + } + + dbp := &dbProvisioner{ + ID: prov.ID, + AuthorityID: db.authorityID, + Type: prov.Type, + Name: prov.Name, + Claims: prov.Claims, + Details: prov.Details, + X509Template: prov.X509Template, + SSHTemplate: prov.SSHTemplate, + CreatedAt: clock.Now(), + } + + return db.save(ctx, dbp.ID, dbp, nil, "provisioner", authorityProvisionersTable) +} + +// UpdateProvisioner saves an updated provisioner to the database. +func (db *DB) UpdateProvisioner(ctx context.Context, prov *mgmt.Provisioner) error { + old, err := db.getDBProvisioner(ctx, prov.ID) + if err != nil { + return err + } + + nu := old.clone() + + // If the provisioner was active but is now deleted ... + if old.DeletedAt.IsZero() && prov.Status == mgmt.StatusDeleted { + nu.DeletedAt = clock.Now() + } + nu.Claims = prov.Claims + nu.Details = prov.Details + nu.X509Template = prov.X509Template + nu.SSHTemplate = prov.SSHTemplate + + return db.save(ctx, old.ID, nu, old, "provisioner", authorityProvisionersTable) +} diff --git a/authority/mgmt/errors.go b/authority/mgmt/errors.go new file mode 100644 index 00000000..f8b6fd65 --- /dev/null +++ b/authority/mgmt/errors.go @@ -0,0 +1,191 @@ +package mgmt + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/errs" + "github.com/smallstep/certificates/logging" +) + +// ProblemType is the type of the ACME problem. +type ProblemType int + +const ( + // ErrorNotFoundType resource not found. + ErrorNotFoundType ProblemType = iota + // ErrorAuthorityMismatchType resource Authority ID does not match the + // context Authority ID. + ErrorAuthorityMismatchType + // ErrorDeletedType resource has been deleted. + ErrorDeletedType + // ErrorServerInternalType internal server error. + ErrorServerInternalType +) + +// String returns the string representation of the acme problem type, +// fulfilling the Stringer interface. +func (ap ProblemType) String() string { + switch ap { + case ErrorNotFoundType: + return "notFound" + case ErrorAuthorityMismatchType: + return "authorityMismatch" + case ErrorDeletedType: + return "deleted" + case ErrorServerInternalType: + return "internalServerError" + default: + return fmt.Sprintf("unsupported error type '%d'", int(ap)) + } +} + +type errorMetadata struct { + details string + status int + typ string + String string +} + +var ( + errorServerInternalMetadata = errorMetadata{ + typ: ErrorServerInternalType.String(), + details: "the server experienced an internal error", + status: 500, + } + errorMap = map[ProblemType]errorMetadata{ + ErrorNotFoundType: { + typ: ErrorNotFoundType.String(), + details: "resource not found", + status: 400, + }, + ErrorAuthorityMismatchType: { + typ: ErrorAuthorityMismatchType.String(), + details: "resource not owned by authority", + status: 401, + }, + ErrorDeletedType: { + typ: ErrorNotFoundType.String(), + details: "resource is deleted", + status: 403, + }, + ErrorServerInternalType: errorServerInternalMetadata, + } +) + +// Error represents an ACME +type Error struct { + Type string `json:"type"` + Detail string `json:"detail"` + Subproblems []interface{} `json:"subproblems,omitempty"` + Identifier interface{} `json:"identifier,omitempty"` + Err error `json:"-"` + Status int `json:"-"` +} + +// NewError creates a new Error type. +func NewError(pt ProblemType, msg string, args ...interface{}) *Error { + return newError(pt, errors.Errorf(msg, args...)) +} + +func newError(pt ProblemType, err error) *Error { + meta, ok := errorMap[pt] + if !ok { + meta = errorServerInternalMetadata + return &Error{ + Type: meta.typ, + Detail: meta.details, + Status: meta.status, + Err: err, + } + } + + return &Error{ + Type: meta.typ, + Detail: meta.details, + Status: meta.status, + Err: err, + } +} + +// NewErrorISE creates a new ErrorServerInternalType Error. +func NewErrorISE(msg string, args ...interface{}) *Error { + return NewError(ErrorServerInternalType, msg, args...) +} + +// WrapError attempts to wrap the internal error. +func WrapError(typ ProblemType, err error, msg string, args ...interface{}) *Error { + switch e := err.(type) { + case nil: + return nil + case *Error: + if e.Err == nil { + e.Err = errors.Errorf(msg+"; "+e.Detail, args...) + } else { + e.Err = errors.Wrapf(e.Err, msg, args...) + } + return e + default: + return newError(typ, errors.Wrapf(err, msg, args...)) + } +} + +// WrapErrorISE shortcut to wrap an internal server error type. +func WrapErrorISE(err error, msg string, args ...interface{}) *Error { + return WrapError(ErrorServerInternalType, err, msg, args...) +} + +// StatusCode returns the status code and implements the StatusCoder interface. +func (e *Error) StatusCode() int { + return e.Status +} + +// Error allows AError to implement the error interface. +func (e *Error) Error() string { + return e.Detail +} + +// Cause returns the internal error and implements the Causer interface. +func (e *Error) Cause() error { + if e.Err == nil { + return errors.New(e.Detail) + } + return e.Err +} + +// ToLog implements the EnableLogger interface. +func (e *Error) ToLog() (interface{}, error) { + b, err := json.Marshal(e) + if err != nil { + return nil, WrapErrorISE(err, "error marshaling authority.Error for logging") + } + return string(b), nil +} + +// WriteError writes to w a JSON representation of the given error. +func WriteError(w http.ResponseWriter, err *Error) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(err.StatusCode()) + + // Write errors in the response writer + if rl, ok := w.(logging.ResponseLogger); ok { + rl.WithFields(map[string]interface{}{ + "error": err.Err, + }) + if os.Getenv("STEPDEBUG") == "1" { + if e, ok := err.Err.(errs.StackTracer); ok { + rl.WithFields(map[string]interface{}{ + "stack-trace": fmt.Sprintf("%+v", e), + }) + } + } + } + + if err := json.NewEncoder(w).Encode(err); err != nil { + log.Println(err) + } +} diff --git a/authority/options.go b/authority/options.go index 9594f989..aaf8ffb3 100644 --- a/authority/options.go +++ b/authority/options.go @@ -7,6 +7,7 @@ import ( "encoding/pem" "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/cas" casapi "github.com/smallstep/certificates/cas/apiv1" @@ -20,7 +21,7 @@ type Option func(*Authority) error // WithConfig replaces the current config with the given one. No validation is // performed in the given value. -func WithConfig(config *Config) Option { +func WithConfig(config *config.Config) Option { return func(a *Authority) error { a.config = config return nil @@ -31,7 +32,7 @@ func WithConfig(config *Config) Option { // the current one. No validation is performed in the given configuration. func WithConfigFile(filename string) Option { return func(a *Authority) (err error) { - a.config, err = LoadConfiguration(filename) + a.config, err = config.LoadConfiguration(filename) return } } @@ -56,7 +57,7 @@ func WithGetIdentityFunc(fn func(ctx context.Context, p provisioner.Interface, e // WithSSHBastionFunc sets a custom function to get the bastion for a // given user-host pair. -func WithSSHBastionFunc(fn func(ctx context.Context, user, host string) (*Bastion, error)) Option { +func WithSSHBastionFunc(fn func(ctx context.Context, user, host string) (*config.Bastion, error)) Option { return func(a *Authority) error { a.sshBastionFunc = fn return nil @@ -65,7 +66,7 @@ func WithSSHBastionFunc(fn func(ctx context.Context, user, host string) (*Bastio // WithSSHGetHosts sets a custom function to get the bastion for a // given user-host pair. -func WithSSHGetHosts(fn func(ctx context.Context, cert *x509.Certificate) ([]Host, error)) Option { +func WithSSHGetHosts(fn func(ctx context.Context, cert *x509.Certificate) ([]config.Host, error)) Option { return func(a *Authority) error { a.sshGetHostsFunc = fn return nil diff --git a/authority/provisioner/claims.go b/authority/provisioner/claims.go index 997d9ba3..6c87792c 100644 --- a/authority/provisioner/claims.go +++ b/authority/provisioner/claims.go @@ -7,6 +7,27 @@ import ( "golang.org/x/crypto/ssh" ) +type _Claims struct { + *X509Claims `json:"x509Claims"` + *SSHClaims `json:"sshClaims"` + DisableRenewal *bool `json:"disableRenewal"` +} + +type X509Claims struct { + Durations *Durations `json:"durations"` +} + +type SSHClaims struct { + UserDuration *Durations `json:"userDurations"` + HostDuration *Durations `json:"hostDuration"` +} + +type Durations struct { + Min string `json:"min"` + Max string `json:"max"` + Default string `json:"default"` +} + // Claims so that individual provisioners can override global claims. type Claims struct { // TLS CA properties diff --git a/authority/ssh.go b/authority/ssh.go index bb0ff562..335b6702 100644 --- a/authority/ssh.go +++ b/authority/ssh.go @@ -10,11 +10,11 @@ import ( "time" "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/templates" - "go.step.sm/crypto/jose" "go.step.sm/crypto/randutil" "go.step.sm/crypto/sshutil" "golang.org/x/crypto/ssh" @@ -32,103 +32,17 @@ const ( SSHAddUserCommand = "sudo useradd -m ; nc -q0 localhost 22" ) -// SSHConfig contains the user and host keys. -type SSHConfig struct { - HostKey string `json:"hostKey"` - UserKey string `json:"userKey"` - Keys []*SSHPublicKey `json:"keys,omitempty"` - AddUserPrincipal string `json:"addUserPrincipal,omitempty"` - AddUserCommand string `json:"addUserCommand,omitempty"` - Bastion *Bastion `json:"bastion,omitempty"` -} - -// Bastion contains the custom properties used on bastion. -type Bastion struct { - Hostname string `json:"hostname"` - User string `json:"user,omitempty"` - Port string `json:"port,omitempty"` - Command string `json:"cmd,omitempty"` - Flags string `json:"flags,omitempty"` -} - -// HostTag are tagged with k,v pairs. These tags are how a user is ultimately -// associated with a host. -type HostTag struct { - ID string - Name string - Value string -} - -// Host defines expected attributes for an ssh host. -type Host struct { - HostID string `json:"hid"` - HostTags []HostTag `json:"host_tags"` - Hostname string `json:"hostname"` -} - -// Validate checks the fields in SSHConfig. -func (c *SSHConfig) Validate() error { - if c == nil { - return nil - } - for _, k := range c.Keys { - if err := k.Validate(); err != nil { - return err - } - } - return nil -} - -// SSHPublicKey contains a public key used by federated CAs to keep old signing -// keys for this ca. -type SSHPublicKey struct { - Type string `json:"type"` - Federated bool `json:"federated"` - Key jose.JSONWebKey `json:"key"` - publicKey ssh.PublicKey -} - -// Validate checks the fields in SSHPublicKey. -func (k *SSHPublicKey) Validate() error { - switch { - case k.Type == "": - return errors.New("type cannot be empty") - case k.Type != provisioner.SSHHostCert && k.Type != provisioner.SSHUserCert: - return errors.Errorf("invalid type %s, it must be user or host", k.Type) - case !k.Key.IsPublic(): - return errors.New("invalid key type, it must be a public key") - } - - key, err := ssh.NewPublicKey(k.Key.Key) - if err != nil { - return errors.Wrap(err, "error creating ssh key") - } - k.publicKey = key - return nil -} - -// PublicKey returns the ssh public key. -func (k *SSHPublicKey) PublicKey() ssh.PublicKey { - return k.publicKey -} - -// SSHKeys represents the SSH User and Host public keys. -type SSHKeys struct { - UserKeys []ssh.PublicKey - HostKeys []ssh.PublicKey -} - // GetSSHRoots returns the SSH User and Host public keys. -func (a *Authority) GetSSHRoots(context.Context) (*SSHKeys, error) { - return &SSHKeys{ +func (a *Authority) GetSSHRoots(context.Context) (*config.SSHKeys, error) { + return &config.SSHKeys{ HostKeys: a.sshCAHostCerts, UserKeys: a.sshCAUserCerts, }, nil } // GetSSHFederation returns the public keys for federated SSH signers. -func (a *Authority) GetSSHFederation(context.Context) (*SSHKeys, error) { - return &SSHKeys{ +func (a *Authority) GetSSHFederation(context.Context) (*config.SSHKeys, error) { + return &config.SSHKeys{ HostKeys: a.sshCAHostFederatedCerts, UserKeys: a.sshCAUserFederatedCerts, }, nil @@ -194,7 +108,7 @@ func (a *Authority) GetSSHConfig(ctx context.Context, typ string, data map[strin // GetSSHBastion returns the bastion configuration, for the given pair user, // hostname. -func (a *Authority) GetSSHBastion(ctx context.Context, user string, hostname string) (*Bastion, error) { +func (a *Authority) GetSSHBastion(ctx context.Context, user string, hostname string) (*config.Bastion, error) { if a.sshBastionFunc != nil { bs, err := a.sshBastionFunc(ctx, user, hostname) return bs, errs.Wrap(http.StatusInternalServerError, err, "authority.GetSSHBastion") @@ -568,7 +482,7 @@ func (a *Authority) CheckSSHHost(ctx context.Context, principal string, token st } // GetSSHHosts returns a list of valid host principals. -func (a *Authority) GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([]Host, error) { +func (a *Authority) GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([]config.Host, error) { if a.sshGetHostsFunc != nil { hosts, err := a.sshGetHostsFunc(ctx, cert) return hosts, errs.Wrap(http.StatusInternalServerError, err, "getSSHHosts") @@ -578,9 +492,9 @@ func (a *Authority) GetSSHHosts(ctx context.Context, cert *x509.Certificate) ([] return nil, errs.Wrap(http.StatusInternalServerError, err, "getSSHHosts") } - hosts := make([]Host, len(hostnames)) + hosts := make([]config.Host, len(hostnames)) for i, hn := range hostnames { - hosts[i] = Host{Hostname: hn} + hosts[i] = config.Host{Hostname: hn} } return hosts, nil } diff --git a/authority/tls.go b/authority/tls.go index b7b2f936..1c8b3b8c 100644 --- a/authority/tls.go +++ b/authority/tls.go @@ -12,6 +12,7 @@ import ( "time" "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" casapi "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/db" @@ -23,14 +24,14 @@ import ( ) // GetTLSOptions returns the tls options configured. -func (a *Authority) GetTLSOptions() *TLSOptions { +func (a *Authority) GetTLSOptions() *config.TLSOptions { return a.config.TLS } var oidAuthorityKeyIdentifier = asn1.ObjectIdentifier{2, 5, 29, 35} var oidSubjectKeyIdentifier = asn1.ObjectIdentifier{2, 5, 29, 14} -func withDefaultASN1DN(def *ASN1DN) provisioner.CertificateModifierFunc { +func withDefaultASN1DN(def *config.ASN1DN) provisioner.CertificateModifierFunc { return func(crt *x509.Certificate, opts provisioner.SignOptions) error { if def == nil { return errors.New("default ASN1DN template cannot be nil") diff --git a/ca/bootstrap.go b/ca/bootstrap.go index c9e859bf..5f06e986 100644 --- a/ca/bootstrap.go +++ b/ca/bootstrap.go @@ -39,40 +39,25 @@ func Bootstrap(token string) (*Client, error) { return NewClient(claims.Audience[0], WithRootSHA256(claims.SHA)) } -// BootstrapServer is a helper function that using the given token returns the -// given http.Server configured with a TLS certificate signed by the Certificate -// Authority. By default the server will kick off a routine that will renew the +// BootstrapClient is a helper function that using the given bootstrap token +// return an http.Client configured with a Transport prepared to do TLS +// connections using the client certificate returned by the certificate +// authority. By default the server will kick off a routine that will renew the // certificate after 2/3rd of the certificate's lifetime has expired. // -// Without any extra option the server will be configured for mTLS, it will -// require and verify clients certificates, but options can be used to drop this -// requirement, the most common will be only verify the certs if given with -// ca.VerifyClientCertIfGiven(), or add extra CAs with -// ca.AddClientCA(*x509.Certificate). -// // Usage: // // Default example with certificate rotation. -// srv, err := ca.BootstrapServer(context.Background(), token, &http.Server{ -// Addr: ":443", -// Handler: handler, -// }) +// client, err := ca.BootstrapClient(ctx.Background(), token) // // // Example canceling automatic certificate rotation. // ctx, cancel := context.WithCancel(context.Background()) // defer cancel() -// srv, err := ca.BootstrapServer(ctx, token, &http.Server{ -// Addr: ":443", -// Handler: handler, -// }) +// client, err := ca.BootstrapClient(ctx, token) // if err != nil { -// return err +// return err // } -// srv.ListenAndServeTLS("", "") -func BootstrapServer(ctx context.Context, token string, base *http.Server, options ...TLSOption) (*http.Server, error) { - if base.TLSConfig != nil { - return nil, errors.New("server TLSConfig is already set") - } - +// resp, err := client.Get("https://internal.smallstep.com") +func BootstrapClient(ctx context.Context, token string, options ...TLSOption) (*http.Client, error) { client, err := Bootstrap(token) if err != nil { return nil, err @@ -88,37 +73,53 @@ func BootstrapServer(ctx context.Context, token string, base *http.Server, optio return nil, err } - // Make sure the tlsConfig have all supported roots on ClientCAs and RootCAs - options = append(options, AddRootsToCAs()) + // Make sure the tlsConfig have all supported roots on RootCAs + options = append(options, AddRootsToRootCAs()) - tlsConfig, err := client.GetServerTLSConfig(ctx, sign, pk, options...) + transport, err := client.Transport(ctx, sign, pk, options...) if err != nil { return nil, err } - base.TLSConfig = tlsConfig - return base, nil + return &http.Client{ + Transport: transport, + }, nil } -// BootstrapClient is a helper function that using the given bootstrap token -// return an http.Client configured with a Transport prepared to do TLS -// connections using the client certificate returned by the certificate -// authority. By default the server will kick off a routine that will renew the +// BootstrapServer is a helper function that using the given token returns the +// given http.Server configured with a TLS certificate signed by the Certificate +// Authority. By default the server will kick off a routine that will renew the // certificate after 2/3rd of the certificate's lifetime has expired. // +// Without any extra option the server will be configured for mTLS, it will +// require and verify clients certificates, but options can be used to drop this +// requirement, the most common will be only verify the certs if given with +// ca.VerifyClientCertIfGiven(), or add extra CAs with +// ca.AddClientCA(*x509.Certificate). +// // Usage: // // Default example with certificate rotation. -// client, err := ca.BootstrapClient(ctx.Background(), token) +// srv, err := ca.BootstrapServer(context.Background(), token, &http.Server{ +// Addr: ":443", +// Handler: handler, +// }) // // // Example canceling automatic certificate rotation. // ctx, cancel := context.WithCancel(context.Background()) // defer cancel() -// client, err := ca.BootstrapClient(ctx, token) +// srv, err := ca.BootstrapServer(ctx, token, &http.Server{ +// Addr: ":443", +// Handler: handler, +// }) // if err != nil { -// return err +// return err // } -// resp, err := client.Get("https://internal.smallstep.com") -func BootstrapClient(ctx context.Context, token string, options ...TLSOption) (*http.Client, error) { +// srv.ListenAndServeTLS("", "") +func BootstrapServer(ctx context.Context, token string, base *http.Server, options ...TLSOption) (*http.Server, error) { + if base.TLSConfig != nil { + return nil, errors.New("server TLSConfig is already set") + } + client, err := Bootstrap(token) if err != nil { return nil, err @@ -134,17 +135,16 @@ func BootstrapClient(ctx context.Context, token string, options ...TLSOption) (* return nil, err } - // Make sure the tlsConfig have all supported roots on RootCAs - options = append(options, AddRootsToRootCAs()) + // Make sure the tlsConfig have all supported roots on ClientCAs and RootCAs + options = append(options, AddRootsToCAs()) - transport, err := client.Transport(ctx, sign, pk, options...) + tlsConfig, err := client.GetServerTLSConfig(ctx, sign, pk, options...) if err != nil { return nil, err } - return &http.Client{ - Transport: transport, - }, nil + base.TLSConfig = tlsConfig + return base, nil } // BootstrapListener is a helper function that using the given token returns a diff --git a/ca/ca.go b/ca/ca.go index e23be140..d9bcdd5e 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -16,6 +16,8 @@ import ( acmeNoSQL "github.com/smallstep/certificates/acme/db/nosql" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" + "github.com/smallstep/certificates/authority/mgmt" "github.com/smallstep/certificates/db" "github.com/smallstep/certificates/logging" "github.com/smallstep/certificates/monitoring" @@ -74,14 +76,14 @@ func WithDatabase(db db.AuthDB) Option { // the HTTP server, set ups the middlewares and the HTTP handlers. type CA struct { auth *authority.Authority - config *authority.Config + config *config.Config srv *server.Server opts *options renewer *TLSRenewer } // New creates and initializes the CA with the given configuration and options. -func New(config *authority.Config, opts ...Option) (*CA, error) { +func New(config *config.Config, opts ...Option) (*CA, error) { ca := &CA{ config: config, opts: new(options), @@ -91,7 +93,7 @@ func New(config *authority.Config, opts ...Option) (*CA, error) { } // Init initializes the CA with the given configuration. -func (ca *CA) Init(config *authority.Config) (*CA, error) { +func (ca *CA) Init(config *config.Config) (*CA, error) { // Intermediate Password. if len(ca.opts.password) > 0 { ca.config.Password = string(ca.opts.password) @@ -146,7 +148,7 @@ func (ca *CA) Init(config *authority.Config) (*CA, error) { if config.DB == nil { acmeDB = nil } else { - acmeDB, err = acmeNoSQL.New(auth.GetDatabase().(nosql.DB)) + acmeDB, err = acmeNoSQL.New(auth.GetDatabase().(nosql.DB), mgmt.DefaultAuthorityID) if err != nil { return nil, errors.Wrap(err, "error configuring ACME DB interface") } @@ -218,7 +220,7 @@ func (ca *CA) Stop() error { // Reload reloads the configuration of the CA and calls to the server Reload // method. func (ca *CA) Reload() error { - config, err := authority.LoadConfiguration(ca.opts.configFile) + config, err := config.LoadConfiguration(ca.opts.configFile) if err != nil { return errors.Wrap(err, "error reloading ca configuration") } diff --git a/ca/mgmtClient.go b/ca/mgmtClient.go new file mode 100644 index 00000000..1172218f --- /dev/null +++ b/ca/mgmtClient.go @@ -0,0 +1,107 @@ +package ca + +import ( + "net/http" + "net/url" + "path" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/authority/mgmt" +) + +// MgmtClient implements an HTTP client for the CA server. +type MgmtClient struct { + client *uaClient + endpoint *url.URL + retryFunc RetryFunc + opts []ClientOption +} + +// NewMgmtClient creates a new MgmtClient with the given endpoint and options. +func NewMgmtClient(endpoint string, opts ...ClientOption) (*MgmtClient, error) { + u, err := parseEndpoint(endpoint) + if err != nil { + return nil, err + } + // Retrieve transport from options. + o := new(clientOptions) + if err := o.apply(opts); err != nil { + return nil, err + } + tr, err := o.getTransport(endpoint) + if err != nil { + return nil, err + } + + return &MgmtClient{ + client: newClient(tr), + endpoint: u, + retryFunc: o.retryFunc, + opts: opts, + }, nil +} + +func (c *MgmtClient) retryOnError(r *http.Response) bool { + if c.retryFunc != nil { + if c.retryFunc(r.StatusCode) { + o := new(clientOptions) + if err := o.apply(c.opts); err != nil { + return false + } + tr, err := o.getTransport(c.endpoint.String()) + if err != nil { + return false + } + r.Body.Close() + c.client.SetTransport(tr) + return true + } + } + return false +} + +// GetAdmin performs the GET /config/admin/{id} request to the CA. +func (c *MgmtClient) GetAdmin(id string) (*mgmt.Admin, error) { + var retried bool + u := c.endpoint.ResolveReference(&url.URL{Path: path.Join("/config/admin", id)}) +retry: + resp, err := c.client.Get(u.String()) + if err != nil { + return nil, errors.Wrapf(err, "client GET %s failed", u) + } + if resp.StatusCode >= 400 { + if !retried && c.retryOnError(resp) { + retried = true + goto retry + } + return nil, readError(resp.Body) + } + var adm = new(mgmt.Admin) + if err := readJSON(resp.Body, adm); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return adm, nil +} + +// GetAdmins performs the GET /config/admins request to the CA. +func (c *MgmtClient) GetAdmins() ([]*mgmt.Admin, error) { + var retried bool + u := c.endpoint.ResolveReference(&url.URL{Path: "/config/admins"}) +retry: + resp, err := c.client.Get(u.String()) + if err != nil { + return nil, errors.Wrapf(err, "client GET %s failed", u) + } + if resp.StatusCode >= 400 { + if !retried && c.retryOnError(resp) { + retried = true + goto retry + } + return nil, readError(resp.Body) + } + var admins = new([]*mgmt.Admin) + if err := readJSON(resp.Body, admins); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return *admins, nil +} diff --git a/ca/tls.go b/ca/tls.go index e4f585fe..cb9f4707 100644 --- a/ca/tls.go +++ b/ca/tls.go @@ -103,7 +103,6 @@ func (c *Client) getClientTLSConfig(ctx context.Context, sign *api.SignResponse, return nil, nil, err } - // Update renew function with transport tr := getDefaultTransport(tlsConfig) // Use mutable tls.Config on renew tr.DialTLS = c.buildDialTLS(tlsCtx) // nolint:staticcheck diff --git a/commands/app.go b/commands/app.go index aff9d473..8833726c 100644 --- a/commands/app.go +++ b/commands/app.go @@ -11,7 +11,7 @@ import ( "unicode" "github.com/pkg/errors" - "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/ca" "github.com/urfave/cli" "go.step.sm/cli-utils/errs" @@ -56,7 +56,7 @@ func appAction(ctx *cli.Context) error { } configFile := ctx.Args().Get(0) - config, err := authority.LoadConfiguration(configFile) + config, err := config.LoadConfiguration(configFile) if err != nil { fatal(err) } diff --git a/commands/onboard.go b/commands/onboard.go index 13c32304..251a4b47 100644 --- a/commands/onboard.go +++ b/commands/onboard.go @@ -9,7 +9,7 @@ import ( "os" "github.com/pkg/errors" - "github.com/smallstep/certificates/authority" + "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/cas/apiv1" "github.com/smallstep/certificates/pki" @@ -162,7 +162,7 @@ func onboardAction(ctx *cli.Context) error { return nil } -func onboardPKI(config onboardingConfiguration) (*authority.Config, string, error) { +func onboardPKI(config onboardingConfiguration) (*config.Config, string, error) { p, err := pki.New(apiv1.Options{ Type: apiv1.SoftCAS, IsCreator: true, diff --git a/pki/pki.go b/pki/pki.go index c95ca985..e4e7bad3 100644 --- a/pki/pki.go +++ b/pki/pki.go @@ -19,7 +19,7 @@ import ( "time" "github.com/pkg/errors" - "github.com/smallstep/certificates/authority" + authconfig "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/cas" @@ -481,12 +481,12 @@ type caDefaults struct { } // Option is the type for modifiers over the auth config object. -type Option func(c *authority.Config) error +type Option func(c *authconfig.Config) error // WithDefaultDB is a configuration modifier that adds a default DB stanza to // the authority config. func WithDefaultDB() Option { - return func(c *authority.Config) error { + return func(c *authconfig.Config) error { c.DB = &db.Config{ Type: "badger", DataSource: GetDBPath(), @@ -498,14 +498,14 @@ func WithDefaultDB() Option { // WithoutDB is a configuration modifier that adds a default DB stanza to // the authority config. func WithoutDB() Option { - return func(c *authority.Config) error { + return func(c *authconfig.Config) error { c.DB = nil return nil } } // GenerateConfig returns the step certificates configuration. -func (p *PKI) GenerateConfig(opt ...Option) (*authority.Config, error) { +func (p *PKI) GenerateConfig(opt ...Option) (*authconfig.Config, error) { key, err := p.ottPrivateKey.CompactSerialize() if err != nil { return nil, errors.Wrap(err, "error serializing private key") @@ -523,7 +523,7 @@ func (p *PKI) GenerateConfig(opt ...Option) (*authority.Config, error) { authorityOptions = &p.casOptions } - config := &authority.Config{ + config := &authconfig.Config{ Root: []string{p.root}, FederatedRoots: []string{}, IntermediateCert: p.intermediate, @@ -535,22 +535,22 @@ func (p *PKI) GenerateConfig(opt ...Option) (*authority.Config, error) { Type: "badger", DataSource: GetDBPath(), }, - AuthorityConfig: &authority.AuthConfig{ + AuthorityConfig: &authconfig.AuthConfig{ Options: authorityOptions, DisableIssuedAtCheck: false, Provisioners: provisioner.List{prov}, }, - TLS: &authority.TLSOptions{ - MinVersion: authority.DefaultTLSMinVersion, - MaxVersion: authority.DefaultTLSMaxVersion, - Renegotiation: authority.DefaultTLSRenegotiation, - CipherSuites: authority.DefaultTLSCipherSuites, + TLS: &authconfig.TLSOptions{ + MinVersion: authconfig.DefaultTLSMinVersion, + MaxVersion: authconfig.DefaultTLSMaxVersion, + Renegotiation: authconfig.DefaultTLSRenegotiation, + CipherSuites: authconfig.DefaultTLSCipherSuites, }, Templates: p.getTemplates(), } if p.enableSSH { enableSSHCA := true - config.SSH = &authority.SSHConfig{ + config.SSH = &authconfig.SSHConfig{ HostKey: p.sshHostKey, UserKey: p.sshUserKey, }