From 17cd70cea546e287713a3d4c086528a85abefa2e Mon Sep 17 00:00:00 2001 From: Niall Sheridan Date: Tue, 4 Oct 2016 14:37:01 -0700 Subject: Add support for Hashicorp Vault Vault is supported for the following: As a well-known filesystem for TLS cert, TLS key and SSH signing key. For configuration secrets for cookie_secret, csrf_secret, oauth_client_id and oauth_client_secret options. --- server/certutil/util.go | 10 --- server/certutil/util_test.go | 16 ----- server/config/config.go | 70 ++++++++++++++++++- server/fs/s3.go | 153 ------------------------------------------ server/helpers/vault/vault.go | 55 +++++++++++++++ server/store/store.go | 4 +- server/util/util.go | 10 +++ server/util/util_test.go | 16 +++++ server/wkfs/s3fs/s3.go | 153 ++++++++++++++++++++++++++++++++++++++++++ server/wkfs/vaultfs/vault.go | 91 +++++++++++++++++++++++++ 10 files changed, 394 insertions(+), 184 deletions(-) delete mode 100644 server/certutil/util.go delete mode 100644 server/certutil/util_test.go delete mode 100644 server/fs/s3.go create mode 100644 server/helpers/vault/vault.go create mode 100644 server/util/util.go create mode 100644 server/util/util_test.go create mode 100644 server/wkfs/s3fs/s3.go create mode 100644 server/wkfs/vaultfs/vault.go (limited to 'server') diff --git a/server/certutil/util.go b/server/certutil/util.go deleted file mode 100644 index eb1900b..0000000 --- a/server/certutil/util.go +++ /dev/null @@ -1,10 +0,0 @@ -package certutil - -import "golang.org/x/crypto/ssh" - -// GetPublicKey marshals a ssh certificate to a string. -func GetPublicKey(cert *ssh.Certificate) string { - marshaled := ssh.MarshalAuthorizedKey(cert) - // Strip trailing newline - return string(marshaled[:len(marshaled)-1]) -} diff --git a/server/certutil/util_test.go b/server/certutil/util_test.go deleted file mode 100644 index df42b90..0000000 --- a/server/certutil/util_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package certutil - -import ( - "testing" - - "github.com/nsheridan/cashier/testdata" - "golang.org/x/crypto/ssh" -) - -func TestGetPublicKey(t *testing.T) { - t.Parallel() - c, _, _, _, _ := ssh.ParseAuthorizedKey(testdata.Cert) - if GetPublicKey(c.(*ssh.Certificate)) != string(testdata.Cert) { - t.Fail() - } -} diff --git a/server/config/config.go b/server/config/config.go index 3587e9f..9678f6d 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/hashicorp/go-multierror" + "github.com/nsheridan/cashier/server/helpers/vault" "github.com/spf13/viper" ) @@ -16,6 +17,7 @@ type Config struct { Auth *Auth `mapstructure:"auth"` SSH *SSH `mapstructure:"ssh"` AWS *AWS `mapstructure:"aws"` + Vault *Vault `mapstructure:"vault"` } // unmarshalled holds the raw config. @@ -24,6 +26,7 @@ type unmarshalled struct { Auth []Auth `mapstructure:"auth"` SSH []SSH `mapstructure:"ssh"` AWS []AWS `mapstructure:"aws"` + Vault []Vault `mapstructure:"vault"` } // Server holds the configuration specific to the web server and sessions. @@ -66,21 +69,31 @@ type AWS struct { SecretKey string `mapstructure:"secret_key"` } +// Vault holds Hashicorp Vault configuration. +type Vault struct { + Address string `mapstructure:"address"` + Token string `mapstructure:"token"` +} + func verifyConfig(u *unmarshalled) error { var err error if len(u.SSH) == 0 { - err = multierror.Append(errors.New("missing ssh config block")) + err = multierror.Append(errors.New("missing ssh config section")) } if len(u.Auth) == 0 { - err = multierror.Append(errors.New("missing auth config block")) + err = multierror.Append(errors.New("missing auth config section")) } if len(u.Server) == 0 { - err = multierror.Append(errors.New("missing server config block")) + err = multierror.Append(errors.New("missing server config section")) } if len(u.AWS) == 0 { // AWS config is optional u.AWS = append(u.AWS, AWS{}) } + if len(u.Vault) == 0 { + // Vault config is optional + u.Vault = append(u.Vault, Vault{}) + } return err } @@ -106,6 +119,53 @@ func setFromEnv(u *unmarshalled) { } } +func setFromVault(u *unmarshalled) error { + if len(u.Vault) == 0 || u.Vault[0].Token == "" || u.Vault[0].Address == "" { + return nil + } + v, err := vault.NewClient(u.Vault[0].Address, u.Vault[0].Token) + if err != nil { + return err + } + get := func(value string) (string, error) { + if value[:7] == "/vault/" { + return v.Read(value) + } + return value, nil + } + if len(u.Auth) > 0 { + u.Auth[0].OauthClientID, err = get(u.Auth[0].OauthClientID) + if err != nil { + err = multierror.Append(err) + } + u.Auth[0].OauthClientSecret, err = get(u.Auth[0].OauthClientSecret) + if err != nil { + err = multierror.Append(err) + } + } + if len(u.Server) > 0 { + u.Server[0].CSRFSecret, err = get(u.Server[0].CSRFSecret) + if err != nil { + err = multierror.Append(err) + } + u.Server[0].CookieSecret, err = get(u.Server[0].CookieSecret) + if err != nil { + err = multierror.Append(err) + } + } + if len(u.AWS) > 0 { + u.AWS[0].AccessKey, err = get(u.AWS[0].AccessKey) + if err != nil { + err = multierror.Append(err) + } + u.AWS[0].SecretKey, err = get(u.AWS[0].SecretKey) + if err != nil { + err = multierror.Append(err) + } + } + return err +} + // ReadConfig parses a JSON configuration file into a Config struct. func ReadConfig(r io.Reader) (*Config, error) { u := &unmarshalled{} @@ -118,6 +178,9 @@ func ReadConfig(r io.Reader) (*Config, error) { return nil, err } setFromEnv(u) + if err := setFromVault(u); err != nil { + return nil, err + } if err := verifyConfig(u); err != nil { return nil, err } @@ -126,5 +189,6 @@ func ReadConfig(r io.Reader) (*Config, error) { Auth: &u.Auth[0], SSH: &u.SSH[0], AWS: &u.AWS[0], + Vault: &u.Vault[0], }, nil } diff --git a/server/fs/s3.go b/server/fs/s3.go deleted file mode 100644 index e16e7d6..0000000 --- a/server/fs/s3.go +++ /dev/null @@ -1,153 +0,0 @@ -package fs - -import ( - "bytes" - "errors" - "io/ioutil" - "os" - "path" - "strings" - "time" - - "go4.org/wkfs" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/s3" - "github.com/nsheridan/cashier/server/config" -) - -// Register the /s3/ filesystem as a well-known filesystem. -func Register(config *config.AWS) { - ac := &aws.Config{} - // If region is unset the SDK will attempt to read the region from the environment. - if config.Region != "" { - ac.Region = aws.String(config.Region) - } - // Attempt to get credentials from the cashier config. - // Otherwise check for standard credentials. If neither are present register the fs as broken. - // TODO: implement this as a provider. - if config.AccessKey != "" && config.SecretKey != "" { - ac.Credentials = credentials.NewStaticCredentials(config.AccessKey, config.SecretKey, "") - } else { - _, err := session.New().Config.Credentials.Get() - if err != nil { - registerBrokenFS(errors.New("aws credentials not found")) - return - } - } - sc := s3.New(session.New(ac)) - if aws.StringValue(sc.Config.Region) == "" { - registerBrokenFS(errors.New("aws region configuration not found")) - return - } - wkfs.RegisterFS("/s3/", &s3FS{ - sc: sc, - }) -} - -func registerBrokenFS(err error) { - wkfs.RegisterFS("/s3/", &s3FS{ - err: err, - }) -} - -type s3FS struct { - sc *s3.S3 - err error -} - -func (fs *s3FS) parseName(name string) (bucket, fileName string, err error) { - if fs.err != nil { - return "", "", fs.err - } - name = strings.TrimPrefix(name, "/s3/") - i := strings.Index(name, "/") - if i < 0 { - return name, "", nil - } - return name[:i], name[i+1:], nil -} - -// Open opens the named file for reading. -func (fs *s3FS) Open(name string) (wkfs.File, error) { - bucket, fileName, err := fs.parseName(name) - if err != nil { - return nil, err - } - obj, err := fs.sc.GetObject(&s3.GetObjectInput{ - Bucket: &bucket, - Key: &fileName, - }) - if err != nil { - return nil, err - } - defer obj.Body.Close() - slurp, err := ioutil.ReadAll(obj.Body) - if err != nil { - return nil, err - } - return &file{ - name: name, - Reader: bytes.NewReader(slurp), - }, nil -} - -func (fs *s3FS) Stat(name string) (os.FileInfo, error) { return fs.Lstat(name) } -func (fs *s3FS) Lstat(name string) (os.FileInfo, error) { - bucket, fileName, err := fs.parseName(name) - if err != nil { - return nil, err - } - obj, err := fs.sc.GetObject(&s3.GetObjectInput{ - Bucket: &bucket, - Key: &fileName, - }) - if err != nil { - if awsErr, ok := err.(awserr.Error); ok { - if awsErr.Code() == "NoSuchKey" { - return nil, os.ErrNotExist - } - } - } - if err != nil { - return nil, err - } - return &statInfo{ - name: path.Base(fileName), - size: *obj.ContentLength, - }, nil -} - -func (fs *s3FS) MkdirAll(path string, perm os.FileMode) error { return nil } - -func (fs *s3FS) OpenFile(name string, flag int, perm os.FileMode) (wkfs.FileWriter, error) { - return nil, errors.New("not implemented") -} - -type statInfo struct { - name string - size int64 - isDir bool - modtime time.Time -} - -func (si *statInfo) IsDir() bool { return si.isDir } -func (si *statInfo) ModTime() time.Time { return si.modtime } -func (si *statInfo) Mode() os.FileMode { return 0644 } -func (si *statInfo) Name() string { return path.Base(si.name) } -func (si *statInfo) Size() int64 { return si.size } -func (si *statInfo) Sys() interface{} { return nil } - -type file struct { - name string - *bytes.Reader -} - -func (*file) Close() error { return nil } -func (f *file) Name() string { return path.Base(f.name) } -func (f *file) Stat() (os.FileInfo, error) { - panic("Stat not implemented on /s3/ files yet") -} diff --git a/server/helpers/vault/vault.go b/server/helpers/vault/vault.go new file mode 100644 index 0000000..bec18b9 --- /dev/null +++ b/server/helpers/vault/vault.go @@ -0,0 +1,55 @@ +package vault + +import ( + "fmt" + "strings" + + "github.com/hashicorp/vault/api" +) + +// NewClient returns a new vault client. +func NewClient(address, token string) (*Client, error) { + config := &api.Config{ + Address: address, + } + client, err := api.NewClient(config) + if err != nil { + return nil, err + } + client.SetToken(token) + return &Client{ + vault: client, + }, nil +} + +func parseName(name string) (path, key string) { + name = strings.TrimPrefix(name, "/vault/") + i := strings.LastIndex(name, "/") + if i < 0 { + return name, "" + } + return name[:i], name[i+1:] +} + +// Client is a simple client for vault. +type Client struct { + vault *api.Client +} + +// Read returns a secret for a given path and key of the form `/vault/secret/path/key`. +// If the requested key cannot be read the original string is returned along with an error. +func (c *Client) Read(value string) (string, error) { + p, k := parseName(value) + data, err := c.vault.Logical().Read(p) + if err != nil { + return value, err + } + if data == nil { + return value, fmt.Errorf("no such key %s", k) + } + secret, ok := data.Data[k] + if !ok { + return value, fmt.Errorf("no such key %s", k) + } + return secret.(string), nil +} diff --git a/server/store/store.go b/server/store/store.go index a846bda..c039d3c 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -5,7 +5,7 @@ import ( "golang.org/x/crypto/ssh" - "github.com/nsheridan/cashier/server/certutil" + "github.com/nsheridan/cashier/server/util" ) // CertStorer records issued certs in a persistent store for audit and @@ -40,6 +40,6 @@ func parseCertificate(cert *ssh.Certificate) *CertRecord { Principals: cert.ValidPrincipals, CreatedAt: parseTime(cert.ValidAfter), Expires: parseTime(cert.ValidBefore), - Raw: certutil.GetPublicKey(cert), + Raw: util.GetPublicKey(cert), } } diff --git a/server/util/util.go b/server/util/util.go new file mode 100644 index 0000000..10f5eca --- /dev/null +++ b/server/util/util.go @@ -0,0 +1,10 @@ +package util + +import "golang.org/x/crypto/ssh" + +// GetPublicKey marshals a ssh certificate to a string. +func GetPublicKey(cert *ssh.Certificate) string { + marshaled := ssh.MarshalAuthorizedKey(cert) + // Strip trailing newline + return string(marshaled[:len(marshaled)-1]) +} diff --git a/server/util/util_test.go b/server/util/util_test.go new file mode 100644 index 0000000..d294d86 --- /dev/null +++ b/server/util/util_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + + "github.com/nsheridan/cashier/testdata" + "golang.org/x/crypto/ssh" +) + +func TestGetPublicKey(t *testing.T) { + t.Parallel() + c, _, _, _, _ := ssh.ParseAuthorizedKey(testdata.Cert) + if GetPublicKey(c.(*ssh.Certificate)) != string(testdata.Cert) { + t.Fail() + } +} diff --git a/server/wkfs/s3fs/s3.go b/server/wkfs/s3fs/s3.go new file mode 100644 index 0000000..a71d874 --- /dev/null +++ b/server/wkfs/s3fs/s3.go @@ -0,0 +1,153 @@ +package s3fs + +import ( + "bytes" + "errors" + "io/ioutil" + "os" + "path" + "strings" + "time" + + "go4.org/wkfs" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/nsheridan/cashier/server/config" +) + +// Register the /s3/ filesystem as a well-known filesystem. +func Register(config *config.AWS) { + ac := &aws.Config{} + // If region is unset the SDK will attempt to read the region from the environment. + if config.Region != "" { + ac.Region = aws.String(config.Region) + } + // Attempt to get credentials from the cashier config. + // Otherwise check for standard credentials. If neither are present register the fs as broken. + // TODO: implement this as a provider. + if config.AccessKey != "" && config.SecretKey != "" { + ac.Credentials = credentials.NewStaticCredentials(config.AccessKey, config.SecretKey, "") + } else { + _, err := session.New().Config.Credentials.Get() + if err != nil { + registerBrokenFS(errors.New("aws credentials not found")) + return + } + } + sc := s3.New(session.New(ac)) + if aws.StringValue(sc.Config.Region) == "" { + registerBrokenFS(errors.New("aws region configuration not found")) + return + } + wkfs.RegisterFS("/s3/", &s3FS{ + sc: sc, + }) +} + +func registerBrokenFS(err error) { + wkfs.RegisterFS("/s3/", &s3FS{ + err: err, + }) +} + +type s3FS struct { + sc *s3.S3 + err error +} + +func (fs *s3FS) parseName(name string) (bucket, fileName string, err error) { + if fs.err != nil { + return "", "", fs.err + } + name = strings.TrimPrefix(name, "/s3/") + i := strings.Index(name, "/") + if i < 0 { + return name, "", nil + } + return name[:i], name[i+1:], nil +} + +// Open opens the named file for reading. +func (fs *s3FS) Open(name string) (wkfs.File, error) { + bucket, fileName, err := fs.parseName(name) + if err != nil { + return nil, err + } + obj, err := fs.sc.GetObject(&s3.GetObjectInput{ + Bucket: &bucket, + Key: &fileName, + }) + if err != nil { + return nil, err + } + defer obj.Body.Close() + slurp, err := ioutil.ReadAll(obj.Body) + if err != nil { + return nil, err + } + return &file{ + name: name, + Reader: bytes.NewReader(slurp), + }, nil +} + +func (fs *s3FS) Stat(name string) (os.FileInfo, error) { return fs.Lstat(name) } +func (fs *s3FS) Lstat(name string) (os.FileInfo, error) { + bucket, fileName, err := fs.parseName(name) + if err != nil { + return nil, err + } + obj, err := fs.sc.GetObject(&s3.GetObjectInput{ + Bucket: &bucket, + Key: &fileName, + }) + if err != nil { + if awsErr, ok := err.(awserr.Error); ok { + if awsErr.Code() == "NoSuchKey" { + return nil, os.ErrNotExist + } + } + } + if err != nil { + return nil, err + } + return &statInfo{ + name: path.Base(fileName), + size: *obj.ContentLength, + }, nil +} + +func (fs *s3FS) MkdirAll(path string, perm os.FileMode) error { return nil } + +func (fs *s3FS) OpenFile(name string, flag int, perm os.FileMode) (wkfs.FileWriter, error) { + return nil, errors.New("not implemented") +} + +type statInfo struct { + name string + size int64 + isDir bool + modtime time.Time +} + +func (si *statInfo) IsDir() bool { return si.isDir } +func (si *statInfo) ModTime() time.Time { return si.modtime } +func (si *statInfo) Mode() os.FileMode { return 0644 } +func (si *statInfo) Name() string { return path.Base(si.name) } +func (si *statInfo) Size() int64 { return si.size } +func (si *statInfo) Sys() interface{} { return nil } + +type file struct { + name string + *bytes.Reader +} + +func (*file) Close() error { return nil } +func (f *file) Name() string { return path.Base(f.name) } +func (f *file) Stat() (os.FileInfo, error) { + panic("Stat not implemented on /s3/ files yet") +} diff --git a/server/wkfs/vaultfs/vault.go b/server/wkfs/vaultfs/vault.go new file mode 100644 index 0000000..6f11057 --- /dev/null +++ b/server/wkfs/vaultfs/vault.go @@ -0,0 +1,91 @@ +package vaultfs + +import ( + "bytes" + "errors" + "os" + "path" + "time" + + "github.com/nsheridan/cashier/server/config" + "github.com/nsheridan/cashier/server/helpers/vault" + "go4.org/wkfs" +) + +// Register the /vault/ filesystem as a well-known filesystem. +func Register(vc *config.Vault) { + client, err := vault.NewClient(vc.Address, vc.Token) + if err != nil { + registerBrokenFS(err) + return + } + wkfs.RegisterFS("/vault/", &vaultFS{ + client: client, + }) +} + +func registerBrokenFS(err error) { + wkfs.RegisterFS("/vault/", &vaultFS{ + err: err, + }) +} + +type vaultFS struct { + err error + client *vault.Client +} + +// Open opens the named file for reading. +func (fs *vaultFS) Open(name string) (wkfs.File, error) { + secret, err := fs.client.Read(name) + if err != nil { + return nil, err + } + return &file{ + name: name, + Reader: bytes.NewReader([]byte(secret)), + }, nil +} + +func (fs *vaultFS) Stat(name string) (os.FileInfo, error) { return fs.Lstat(name) } +func (fs *vaultFS) Lstat(name string) (os.FileInfo, error) { + secret, err := fs.client.Read(name) + if err != nil { + return nil, err + } + return &statInfo{ + name: path.Base(name), + size: int64(len(secret)), + }, nil +} + +func (fs *vaultFS) MkdirAll(path string, perm os.FileMode) error { return nil } + +func (fs *vaultFS) OpenFile(name string, flag int, perm os.FileMode) (wkfs.FileWriter, error) { + return nil, errors.New("not implemented") +} + +type statInfo struct { + name string + size int64 + isDir bool + modtime time.Time +} + +func (si *statInfo) IsDir() bool { return si.isDir } +func (si *statInfo) ModTime() time.Time { return si.modtime } +func (si *statInfo) Mode() os.FileMode { return 0644 } +func (si *statInfo) Name() string { return path.Base(si.name) } +func (si *statInfo) Size() int64 { return si.size } +func (si *statInfo) Sys() interface{} { return nil } + +type file struct { + name string + *bytes.Reader +} + +func (*file) Close() error { return nil } +func (f *file) Name() string { return path.Base(f.name) } +func (f *file) Stat() (os.FileInfo, error) { + return nil, errors.New("Stat not implemented on /vault/ files") +} -- cgit v1.2.3