From eb210ccc70e78d780bd535a1700d1dd07286c78e Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 3 Oct 2019 19:03:38 -0700 Subject: [PATCH] Add initial support for ssh config. Related to smallstep/cli#170 --- Gopkg.lock | 68 +++++++++++++++- Gopkg.toml | 7 ++ api/api.go | 2 + api/api_test.go | 9 +++ api/ssh.go | 63 +++++++++++++++ authority/authority.go | 19 +++++ authority/config.go | 34 +++++--- authority/ssh.go | 42 +++++++++- ca/client.go | 22 +++++ templates/templates.go | 180 +++++++++++++++++++++++++++++++++++++++++ templates/values.go | 15 ++++ 11 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 templates/templates.go create mode 100644 templates/values.go diff --git a/Gopkg.lock b/Gopkg.lock index a842e25f..1e39946e 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -9,6 +9,22 @@ pruneopts = "UT" revision = "e2d15f34fcf99d5dbb871c820ec73f710fca9815" +[[projects]] + digest = "1:3b10c6fd33854dc41de2cf78b7bae105da94c2789b6fa5b9ac9e593ea43484ac" + name = "github.com/Masterminds/goutils" + packages = ["."] + pruneopts = "UT" + revision = "41ac8693c5c10a92ea1ff5ac3a7f95646f6123b0" + version = "v1.1.0" + +[[projects]] + digest = "1:181a6a5506bd83827d826df5b3272040e92c487516b2fc8edd066be941d68d9e" + name = "github.com/Masterminds/sprig" + packages = ["."] + pruneopts = "UT" + revision = "0e09f04f09aede2cc36cbecad7ec27b0055303e0" + version = "v3.0.0" + [[projects]] branch = "master" digest = "1:454adc7f974228ff789428b6dc098638c57a64aa0718f0bd61e53d3cd39d7a75" @@ -64,6 +80,30 @@ revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" version = "v1.2.0" +[[projects]] + digest = "1:582b704bebaa06b48c29b0cec224a6058a09c86883aaddabde889cd1a5f73e1b" + name = "github.com/google/uuid" + packages = ["."] + pruneopts = "UT" + revision = "0cd6bf5da1e1c83f8b45653022c74f71af0538a4" + version = "v1.1.1" + +[[projects]] + digest = "1:f9a5e090336881be43cfc1cf468330c1bdd60abdc9dd194e0b1ab69f4b94dd7c" + name = "github.com/huandu/xstrings" + packages = ["."] + pruneopts = "UT" + revision = "f02667b379e2fb5916c3cda2cf31e0eb885d79f8" + version = "v1.2.0" + +[[projects]] + digest = "1:78d28d5b84a26159c67ea51996a230da4bc07cac648adaae1dfb5fc0ec8e40d3" + name = "github.com/imdario/mergo" + packages = ["."] + pruneopts = "UT" + revision = "1afb36080aec31e0d1528973ebe6721b191b0369" + version = "v0.3.8" + [[projects]] branch = "master" digest = "1:e51f40f0c19b39c1825eadd07d5c0a98a2ad5942b166d9fc4f54750ce9a04810" @@ -119,6 +159,22 @@ revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c" version = "v0.0.4" +[[projects]] + digest = "1:09ca328575f38b80969ccf857f6d7302f2ce09d53778ea7aaba526cfd2cec739" + name = "github.com/mitchellh/copystructure" + packages = ["."] + pruneopts = "UT" + revision = "9a1b6f44e8da0e0e374624fb0a825a231b00c537" + version = "v1.0.0" + +[[projects]] + digest = "1:2a7e6f8bebdca6bd8bc359c37f01ae1c4ea4f8481eaabf93b1ae4863f15b72c7" + name = "github.com/mitchellh/reflectwalk" + packages = ["."] + pruneopts = "UT" + revision = "3e2c75dfad4fbf904b58782a80fd595c760ad185" + version = "v1.0.1" + [[projects]] branch = "master" digest = "1:ae08d850ba158ea3ba4a7bb90f8372608172d8920644e5a6693b940a1f4e5d01" @@ -245,6 +301,14 @@ pruneopts = "UT" revision = "f80b3f432de0662f07ebd58fe52b0a119fe5dcd9" +[[projects]] + digest = "1:08d65904057412fc0270fc4812a1c90c594186819243160dc779a402d4b6d0bc" + name = "github.com/spf13/cast" + packages = ["."] + pruneopts = "UT" + revision = "8c9545af88b134710ab1cd196795e7f2388358d7" + version = "v1.3.0" + [[projects]] branch = "master" digest = "1:6743b69de0d73e91004e4e201cf4965b59a0fa5caf6f0ffbe0cb9ee8807738a7" @@ -263,7 +327,7 @@ [[projects]] branch = "master" - digest = "1:afc49fe39c8c591fc2c8ddc73adc4c69e67125dde6c58e24c91b3b0cf78602be" + digest = "1:f492081c0e705c98ea169765a0fa60c9cfee2358a2434e96abd5bb6f59dbc190" name = "golang.org/x/crypto" packages = [ "cryptobyte", @@ -276,6 +340,7 @@ "ocsp", "pbkdf2", "poly1305", + "scrypt", "ssh", "ssh/terminal", ] @@ -377,6 +442,7 @@ analyzer-name = "dep" analyzer-version = 1 input-imports = [ + "github.com/Masterminds/sprig", "github.com/go-chi/chi", "github.com/newrelic/go-agent", "github.com/pkg/errors", diff --git a/Gopkg.toml b/Gopkg.toml index 8937374b..55c56d93 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -23,6 +23,9 @@ # non-go = false # go-tests = true # unused-packages = true + +ignored = ["github.com/Masterminds/semver/v3"] + [[override]] name = "gopkg.in/alecthomas/kingpin.v3-unstable" revision = "63abe20a23e29e80bbef8089bd3dee3ac25e5306" @@ -54,3 +57,7 @@ [prune] go-tests = true unused-packages = true + +[[constraint]] + name = "github.com/Masterminds/sprig" + version = "3.0.0" diff --git a/api/api.go b/api/api.go index 9ac60613..a5f5b83f 100644 --- a/api/api.go +++ b/api/api.go @@ -252,6 +252,8 @@ func (h *caHandler) Route(r Router) { // SSH CA r.MethodFunc("POST", "/ssh/sign", h.SignSSH) r.MethodFunc("GET", "/ssh/keys", h.SSHKeys) + r.MethodFunc("POST", "/ssh/config", h.SSHConfig) + r.MethodFunc("POST", "/ssh/config/{type}", h.SSHConfig) // For compatibility with old code: r.MethodFunc("POST", "/re-sign", h.Renew) diff --git a/api/api_test.go b/api/api_test.go index 8c3034d0..d9e125e6 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -29,6 +29,7 @@ import ( "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/logging" + "github.com/smallstep/certificates/templates" "github.com/smallstep/cli/crypto/tlsutil" "github.com/smallstep/cli/jose" "golang.org/x/crypto/ssh" @@ -513,6 +514,7 @@ type mockAuthority struct { getRoots func() ([]*x509.Certificate, error) getFederation func() ([]*x509.Certificate, error) getSSHKeys func() (*authority.SSHKeys, error) + getSSHConfig func(typ string) ([]templates.Output, error) } // TODO: remove once Authorize is deprecated. @@ -625,6 +627,13 @@ func (m *mockAuthority) GetSSHKeys() (*authority.SSHKeys, error) { return m.ret1.(*authority.SSHKeys), m.err } +func (m *mockAuthority) GetSSHConfig(typ string) ([]templates.Output, error) { + if m.getSSHConfig != nil { + return m.getSSHConfig(typ) + } + return m.ret1.([]templates.Output), m.err +} + func Test_caHandler_Route(t *testing.T) { type fields struct { Authority Authority diff --git a/api/ssh.go b/api/ssh.go index edc49a10..14c3b7c9 100644 --- a/api/ssh.go +++ b/api/ssh.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/templates" "golang.org/x/crypto/ssh" ) @@ -17,6 +18,7 @@ type SSHAuthority interface { SignSSH(key ssh.PublicKey, opts provisioner.SSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) SignSSHAddUser(key ssh.PublicKey, cert *ssh.Certificate) (*ssh.Certificate, error) GetSSHKeys() (*authority.SSHKeys, error) + GetSSHConfig(typ string) ([]templates.Output, error) } // SignSSHRequest is the request body of an SSH certificate request. @@ -138,6 +140,34 @@ func (p *SSHPublicKey) UnmarshalJSON(data []byte) error { return nil } +// Template represents the output of a template. +type Template = templates.Output + +// SSHConfigRequest is the request body used to get the SSH configuration +// templates. +type SSHConfigRequest struct { + Type string `json:"type"` +} + +// Validate checks the values of the SSHConfigurationRequest. +func (r *SSHConfigRequest) Validate() error { + switch r.Type { + case "": + r.Type = provisioner.SSHUserCert + return nil + case provisioner.SSHUserCert, provisioner.SSHHostCert: + return nil + default: + return errors.Errorf("unsupported type %s", r.Type) + } +} + +// SSHConfigResponse is the response that returns the rendered templates. +type SSHConfigResponse struct { + UserTemplates []Template `json:"userTemplates,omitempty"` + HostTemplates []Template `json:"hostTemplates,omitempty"` +} + // SignSSH is an HTTP handler that reads an SignSSHRequest with a one-time-token // (ott) from the body and creates a new SSH certificate with the information in // the request. @@ -228,3 +258,36 @@ func (h *caHandler) SSHKeys(w http.ResponseWriter, r *http.Request) { UserKey: user, }) } + +// SSHConfig is an HTTP handler that returns rendered templates for ssh clients +// and servers. +func (h *caHandler) SSHConfig(w http.ResponseWriter, r *http.Request) { + var body SSHConfigRequest + if err := ReadJSON(r.Body, &body); err != nil { + WriteError(w, BadRequest(errors.Wrap(err, "error reading request body"))) + return + } + if err := body.Validate(); err != nil { + WriteError(w, BadRequest(err)) + return + } + + ts, err := h.Authority.GetSSHConfig(body.Type) + if err != nil { + WriteError(w, InternalServerError(err)) + return + } + + var config SSHConfigResponse + switch body.Type { + case provisioner.SSHUserCert: + config.UserTemplates = ts + case provisioner.SSHHostCert: + config.UserTemplates = ts + default: + WriteError(w, InternalServerError(errors.New("it should hot get here"))) + return + } + + JSON(w, config) +} diff --git a/authority/authority.go b/authority/authority.go index 1ddcf13c..1e4c3466 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -8,6 +8,8 @@ import ( "sync" "time" + "github.com/smallstep/certificates/templates" + "github.com/pkg/errors" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/db" @@ -154,6 +156,23 @@ func (a *Authority) init() error { } } + // Configure protected template variables: + if t := a.config.Templates; t != nil { + if t.Variables == nil { + t.Variables = make(map[string]interface{}) + } + var vars templates.Step + if a.config.SSH != nil { + if a.sshCAHostCertSignKey != nil { + vars.SSH.HostKey = a.sshCAHostCertSignKey.PublicKey() + } + if a.sshCAUserCertSignKey != nil { + vars.SSH.UserKey = a.sshCAUserCertSignKey.PublicKey() + } + } + t.Variables["Step"] = vars + } + // JWT numeric dates are seconds. a.startTime = time.Now().Truncate(time.Second) // Set flag indicating that initialization has been completed, and should diff --git a/authority/config.go b/authority/config.go index 99fdf457..a53507dd 100644 --- a/authority/config.go +++ b/authority/config.go @@ -7,6 +7,8 @@ import ( "os" "time" + "github.com/smallstep/certificates/templates" + "github.com/pkg/errors" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/db" @@ -46,19 +48,20 @@ var ( // Config represents the CA configuration and it's mapped to a JSON object. type Config struct { - Root multiString `json:"root"` - FederatedRoots []string `json:"federatedRoots"` - IntermediateCert string `json:"crt"` - IntermediateKey string `json:"key"` - Address string `json:"address"` - DNSNames []string `json:"dnsNames"` - SSH *SSHConfig `json:"ssh,omitempty"` - Logger json.RawMessage `json:"logger,omitempty"` - DB *db.Config `json:"db,omitempty"` - Monitoring json.RawMessage `json:"monitoring,omitempty"` - AuthorityConfig *AuthConfig `json:"authority,omitempty"` - TLS *tlsutil.TLSOptions `json:"tls,omitempty"` - Password string `json:"password,omitempty"` + Root multiString `json:"root"` + FederatedRoots []string `json:"federatedRoots"` + IntermediateCert string `json:"crt"` + IntermediateKey string `json:"key"` + Address string `json:"address"` + DNSNames []string `json:"dnsNames"` + SSH *SSHConfig `json:"ssh,omitempty"` + Logger json.RawMessage `json:"logger,omitempty"` + DB *db.Config `json:"db,omitempty"` + Monitoring json.RawMessage `json:"monitoring,omitempty"` + AuthorityConfig *AuthConfig `json:"authority,omitempty"` + TLS *tlsutil.TLSOptions `json:"tls,omitempty"` + Password string `json:"password,omitempty"` + Templates *templates.Templates `json:"templates,omitempty"` } // AuthConfig represents the configuration options for the authority. @@ -181,6 +184,11 @@ func (c *Config) Validate() error { c.TLS.Renegotiation = c.TLS.Renegotiation || DefaultTLSOptions.Renegotiation } + // Validate templates: nil is ok + if err := c.Templates.Validate(); err != nil { + return err + } + return c.AuthorityConfig.Validate(c.getAudiences()) } diff --git a/authority/ssh.go b/authority/ssh.go index c83ce88b..68b52c9d 100644 --- a/authority/ssh.go +++ b/authority/ssh.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" + "github.com/smallstep/certificates/templates" + "github.com/pkg/errors" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/cli/crypto/randutil" @@ -41,13 +43,51 @@ func (a *Authority) GetSSHKeys() (*SSHKeys, error) { } if keys.UserKey == nil && keys.HostKey == nil { return nil, &apiError{ - err: errors.New("sshConfig: ssh is not configured"), + err: errors.New("getSSHKeys: ssh is not configured"), code: http.StatusNotFound, } } return &keys, nil } +// GetSSHConfig returns rendered templates for clients (user) or servers (host). +func (a *Authority) GetSSHConfig(typ string) ([]templates.Output, error) { + if a.sshCAUserCertSignKey == nil && a.sshCAHostCertSignKey == nil { + return nil, &apiError{ + err: errors.New("getSSHConfig: ssh is not configured"), + code: http.StatusNotFound, + } + } + + var ts []templates.Template + switch typ { + case provisioner.SSHUserCert: + if a.config.Templates != nil && a.config.Templates.SSH != nil { + ts = a.config.Templates.SSH.User + } + case provisioner.SSHHostCert: + if a.config.Templates != nil && a.config.Templates.SSH != nil { + ts = a.config.Templates.SSH.Host + } + default: + return nil, &apiError{ + err: errors.Errorf("getSSHConfig: type %s is not valid", typ), + code: http.StatusBadRequest, + } + } + + // Render templates. + output := []templates.Output{} + for _, t := range ts { + o, err := t.Output(a.config.Templates.Variables) + if err != nil { + return nil, err + } + output = append(output, o) + } + return output, nil +} + // SignSSH creates a signed SSH certificate with the given public key and options. func (a *Authority) SignSSH(key ssh.PublicKey, opts provisioner.SSHOptions, signOpts ...provisioner.SignOption) (*ssh.Certificate, error) { var mods []provisioner.SSHCertificateModifier diff --git a/ca/client.go b/ca/client.go index fc964b89..bbce5ee8 100644 --- a/ca/client.go +++ b/ca/client.go @@ -545,6 +545,28 @@ func (c *Client) SSHKeys() (*api.SSHKeysResponse, error) { return &keys, nil } +// SSHConfig performs the POST request to the CA to get the ssh configuration +// templates. +func (c *Client) SSHConfig(req *api.SSHConfigRequest) (*api.SSHConfigResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "error marshaling request") + } + u := c.endpoint.ResolveReference(&url.URL{Path: "/ssh/config"}) + resp, err := c.client.Post(u.String(), "application/json", bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrapf(err, "client POST %s failed", u) + } + if resp.StatusCode >= 400 { + return nil, readError(resp.Body) + } + var config api.SSHConfigResponse + if err := readJSON(resp.Body, &config); err != nil { + return nil, errors.Wrapf(err, "error reading %s", u) + } + return &config, nil +} + // RootFingerprint is a helper method that returns the current root fingerprint. // It does an health connection and gets the fingerprint from the TLS verified // chains. diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 00000000..740ddc1c --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,180 @@ +package templates + +import ( + "bytes" + "fmt" + "io/ioutil" + "text/template" + + "github.com/Masterminds/sprig" + "github.com/pkg/errors" +) + +// TemplateType defines how a template will be written in disk. +type TemplateType string + +const ( + // Snippet will mark a template as a part of a file. + Snippet TemplateType = "snippet" + // File will mark a templates as a full file. + File TemplateType = "file" +) + +// Output represents the text representation of a rendered template. +type Output struct { + Name string `json:"name"` + Type TemplateType `json:"type"` + Comment string `json:"comment"` + Path string `json:"path"` + Content []byte `json:"content"` +} + +// Templates is a collection of templates and variables. +type Templates struct { + SSH *SSHTemplates `json:"ssh,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +// Validate returns an error if a template is not valid. +func (t *Templates) Validate() (err error) { + if t == nil { + return nil + } + + // Validate members + if err = t.SSH.Validate(); err != nil { + return + } + + // Do not allow "Step" + if t.Variables != nil { + if _, ok := t.Variables["Step"]; ok { + return errors.New("templates variables cannot contain 'step' as a property") + } + } + return nil +} + +// LoadAll preloads all templates in memory. It returns an error if an error is +// found parsing at least one template. +func LoadAll(t *Templates) (err error) { + if t.SSH != nil { + for _, tt := range t.SSH.User { + if err = tt.Load(); err != nil { + return err + } + } + for _, tt := range t.SSH.Host { + if err = tt.Load(); err != nil { + return err + } + } + } + return nil +} + +// SSHTemplates contains the templates defining ssh configuration files. +type SSHTemplates struct { + User []Template `json:"user"` + Host []Template `json:"host"` +} + +// Validate returns an error if a template is not valid. +func (t *SSHTemplates) Validate() (err error) { + if t == nil { + return nil + } + for _, tt := range t.User { + if err = tt.Validate(); err != nil { + return + } + } + for _, tt := range t.Host { + if err = tt.Validate(); err != nil { + return + } + } + return +} + +// Template represents on template file. +type Template struct { + *template.Template + Name string `json:"name"` + Type TemplateType `json:"type"` + TemplatePath string `json:"template"` + Path string `json:"path"` + Comment string `json:"comment"` +} + +// Validate returns an error if the template is not valid. +func (t *Template) Validate() error { + switch { + case t == nil: + return nil + case t.Name == "": + return errors.New("template name cannot be empty") + case t.TemplatePath == "": + return errors.New("template template cannot be empty") + case t.Path == "": + return errors.New("template path cannot be empty") + } + + // Defaults + if t.Type == "" { + t.Type = Snippet + } + if t.Comment == "" { + t.Comment = "#" + } + + return nil +} + +// Load loads the template in memory, returns an error if the parsing of the +// template fails. +func (t *Template) Load() error { + if t.Template == nil { + b, err := ioutil.ReadFile(t.TemplatePath) + if err != nil { + return errors.Wrapf(err, "error reading %s", t.TemplatePath) + } + tmpl, err := template.New(t.Name).Funcs(sprig.TxtFuncMap()).Parse(string(b)) + if err != nil { + return errors.Wrapf(err, "error parsing %s", t.TemplatePath) + } + t.Template = tmpl + } + return nil +} + +// Render executes the template with the given data and returns the rendered +// version. +func (t *Template) Render(data interface{}) ([]byte, error) { + if err := t.Load(); err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + fmt.Println(data) + if err := t.Execute(buf, data); err != nil { + return nil, errors.Wrapf(err, "error executing %s", t.TemplatePath) + } + return buf.Bytes(), nil +} + +// Output renders the template and returns a template.Output struct or an error. +func (t *Template) Output(data interface{}) (Output, error) { + b, err := t.Render(data) + if err != nil { + return Output{}, err + } + + return Output{ + Name: t.Name, + Type: t.Type, + Comment: t.Comment, + Path: t.Path, + Content: b, + }, nil +} diff --git a/templates/values.go b/templates/values.go new file mode 100644 index 00000000..995c2998 --- /dev/null +++ b/templates/values.go @@ -0,0 +1,15 @@ +package templates + +import ( + "golang.org/x/crypto/ssh" +) + +// Step represents the default variables available in the CA. +type Step struct { + SSH StepSSH +} + +type StepSSH struct { + HostKey ssh.PublicKey + UserKey ssh.PublicKey +}