Add initial support for ssh config.

Related to smallstep/cli#170
This commit is contained in:
Mariano Cano 2019-10-03 19:03:38 -07:00 committed by max furman
parent b000b59ee6
commit a35988ff08
9 changed files with 372 additions and 14 deletions

View file

@ -253,6 +253,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)

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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())
}

View file

@ -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

View file

@ -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.

180
templates/templates.go Normal file
View file

@ -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
}

15
templates/values.go Normal file
View file

@ -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
}