diff --git a/cmd/secretsserver/config.go b/cmd/secretsserver/config.go new file mode 100644 index 0000000..8dc4e1f --- /dev/null +++ b/cmd/secretsserver/config.go @@ -0,0 +1,45 @@ +package main + +import ( + "log" + "os" +) + +type config struct { + addr string + storePath string + ca string + crt string + crtKey string +} + +// TODO: Maybe use viper? +func loadConfigFromEnv() *config { + res := config{} + if addr, ok := os.LookupEnv("SECRETS_SERVER_ADDR"); !ok || addr == "" { + log.Fatal("SECRETS_SERVER_ADDR is required") + } else { + res.addr = addr + } + if storePath, ok := os.LookupEnv("SECRETS_SERVER_STORE_PATH"); !ok || storePath == "" { + log.Fatal("SECRETS_SERVER_STORE_PATH is required") + } else { + res.storePath = storePath + } + if ca, ok := os.LookupEnv("SECRETS_SERVER_CA"); !ok || ca == "" { + log.Fatal("SECRETS_SERVER_CA is required") + } else { + res.ca = ca + } + if crt, ok := os.LookupEnv("SECRETS_SERVER_CRT"); !ok || crt == "" { + log.Fatal("SECRETS_SERVER_CRT is required") + } else { + res.crt = crt + } + if crtKey, ok := os.LookupEnv("SECRETS_SERVER_CRT_KEY"); !ok || crtKey == "" { + log.Fatal("SECRETS_SERVER_CRT_KEY is required") + } else { + res.crtKey = crtKey + } + return &res +} diff --git a/cmd/secretsserver/main.go b/cmd/secretsserver/main.go new file mode 100644 index 0000000..f321f23 --- /dev/null +++ b/cmd/secretsserver/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "git.handmadecities.com/HMC/shhh/pkg/secretsserver" + "git.handmadecities.com/HMC/shhh/pkg/secretsstore" +) + +func main() { + cfg := loadConfigFromEnv() + + store, err := secretsstore.New(cfg.storePath) + if err != nil { + panic(err) + } + server := secretsserver.New(store) + log.Fatal(server.ListenAndServeTLS(cfg.addr, cfg.ca, cfg.crt, cfg.crtKey)) +} diff --git a/pkg/secretsserver/server.go b/pkg/secretsserver/server.go new file mode 100644 index 0000000..78256ef --- /dev/null +++ b/pkg/secretsserver/server.go @@ -0,0 +1,102 @@ +package secretsserver + +import ( + "encoding/base64" + "io" + "net/http" + + "git.handmadecities.com/HMC/shhh/pkg/secretsstore" +) + +type Server struct { + store secretsstore.SecretsStore +} + +// New creates a new secrets server that auths through mTLS. +func New( + store secretsstore.SecretsStore, +) *Server { + return &Server{ + store: store, + } +} + +// ListenAndServeTLS binds the server to addr. +// cert must have been signed by ca, any client cert also +// signed by ca will be able to call the serice. +func (s *Server) ListenAndServeTLS(addr, ca, cert, key string) error { + tlsConfig, err := mTLSConfigFromCA(ca) + if err != nil { + return err + } + server := &http.Server{ + Addr: addr, + Handler: s, + TLSConfig: tlsConfig, + // TODO: Tune the rest of the server parameters for robustness. + } + return server.ListenAndServeTLS(cert, key) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // All operations are on the root path. + if r.URL.Path != "" && r.URL.Path != "/" { + w.WriteHeader(http.StatusBadRequest) + // TODO: Better handling of `Write` failing. + w.Write([]byte("requests are only accepted to the root (/) path")) + return + } + + // Decode key from query string parameters. + b64Key := r.URL.Query().Get("key") + if b64Key == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("missing \"key\" query string parameter")) + return + } + key, err := base64.RawURLEncoding.DecodeString(b64Key) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("key was not base64 encoded")) + return + } + + // TODO: Control flow is a little clumsy here, clean it up. + switch r.Method { + case http.MethodGet: + secret, err := s.store.Get(secretsstore.Key(key)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("could not retrieve requested secret: " + err.Error())) + return + } + // We're writing raw bytes. + w.Header().Add("Content-Type", "application/octet-stream") + w.Write(secret) + return + case http.MethodPut: + secret, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("could not read secret bytes from request body: " + err.Error())) + return + } + if err := s.store.Put(secretsstore.Key(key), secret); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("could not store secret: " + err.Error())) + return + } + return + case http.MethodDelete: + if err := s.store.Delete(secretsstore.Key(key)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("could not delete secret: " + err.Error())) + return + } + return + default: + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(r.Method + " method is not allowd")) + return + } +} diff --git a/pkg/secretsserver/tls.go b/pkg/secretsserver/tls.go new file mode 100644 index 0000000..2610c71 --- /dev/null +++ b/pkg/secretsserver/tls.go @@ -0,0 +1,29 @@ +package secretsserver + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "os" +) + +// mTLSConfigFromCA returns a tls config that enforces client auth with certs +// issued by the provided ca. +func mTLSConfigFromCA(ca string) (*tls.Config, error) { + caPEMBytes, err := os.ReadFile(ca) + if err != nil { + return nil, err + } + caCertPool := x509.NewCertPool() + if added := caCertPool.AppendCertsFromPEM(caPEMBytes); !added { + return nil, errors.New("ca file did not contain a valid cert") + } + return &tls.Config{ + // Force server to deman and verify cert from clients. + ClientAuth: tls.RequireAndVerifyClientCert, + // CAs that will be used to verify client certs. + ClientCAs: caCertPool, + // Safer to default to tls 1.3 + MinVersion: tls.VersionTLS13, + }, nil +}