Skip to content

Commit

Permalink
feat: Implement pluggable password generator (#2659)
Browse files Browse the repository at this point in the history
Remove dependency on GoKey for password generator;
if not specified, the built-in password generator
does the equivalent of "openssl rand -base64 33"
which generates a base64 encoded random string
of 264 random bits.

Otherwise, the code looks for a PasswordProvider
in configuration that points to an executable,
and PasswordProviderArgs that specify the
arguments to that executable.  The code then
launches that exectuable, trims the trailing
newline, and uses the result as a password.

Signed-off-by: Bryon Nevis <bryon.nevis@intel.com>
  • Loading branch information
bnevis-i authored Sep 24, 2020
1 parent 855c38c commit ff532ad
Show file tree
Hide file tree
Showing 16 changed files with 441 additions and 107 deletions.
2 changes: 2 additions & 0 deletions cmd/security-secretstore-setup/res/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ TokenProvider = "/security-file-token-provider"
TokenProviderArgs = [ "-confdir", "res-file-token-provider" ]
TokenProviderType = "oneshot"
TokenProviderAdminTokenPath = "/run/edgex/secrets/tokenprovider/secrets-token.json"
PasswordProvider = ""
PasswordProviderArgs = [ ]
RevokeRootTokens = true

[Databases]
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ require (
bitbucket.org/bertimus9/systemstat v0.0.0-20180207000608-0eeff89b0690
github.com/BurntSushi/toml v0.3.1
github.com/OneOfOne/xxhash v1.2.5
github.com/cloudflare/gokey v0.1.0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/edgexfoundry/go-mod-bootstrap v0.0.37
github.com/edgexfoundry/go-mod-configuration v0.0.3
Expand Down
38 changes: 38 additions & 0 deletions internal/security/secretstore/credentialgenerator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Copyright (c) 2020 Intel Corporation
//
// SPDX-License-Identifier: Apache-2.0
//

package secretstore

import (
"context"
"crypto/rand"
"encoding/base64"
)

const randomBytesLength = 33 // 264 bits of entropy

// CredentialGenerator is the interface for pluggable password generators
type CredentialGenerator interface {
Generate(ctx context.Context) (string, error)
}

type defaultCredentialGenerator struct{}

// NewDefaultCredentialGenerator generates random passwords as base64-encoded strings
func NewDefaultCredentialGenerator() CredentialGenerator {
return &defaultCredentialGenerator{}
}

// Generate implementation returns base64-encoded randomBytesLength random bytes
func (cg *defaultCredentialGenerator) Generate(ctx context.Context) (string, error) {
randomBytes := make([]byte, randomBytesLength)
_, err := rand.Read(randomBytes) // all of salt guaranteed to be filled if err==nil
if err != nil {
return "", err
}
newCredential := base64.StdEncoding.EncodeToString(randomBytes)
return newCredential, nil
}
25 changes: 25 additions & 0 deletions internal/security/secretstore/credentialgenerator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// Copyright (c) 2020 Intel Corporation
//
// SPDX-License-Identifier: Apache-2.0
//

package secretstore

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
)

func TestPasswordsAreRandom(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cg := NewDefaultCredentialGenerator()
cred1, err := cg.Generate(ctx)
assert.NoError(t, err)
cred2, err := cg.Generate(ctx)
assert.NoError(t, err)
assert.NotEqual(t, cred1, cred2)
defer cancel()
}
47 changes: 47 additions & 0 deletions internal/security/secretstore/execrunner-mock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// Copyright (c) 2020 Intel Corporation
//
// SPDX-License-Identifier: Apache-2.0
//

package secretstore

import (
"context"
"io"

"github.com/stretchr/testify/mock"
)

type mockExecRunner struct {
mock.Mock
}

func (m *mockExecRunner) SetStdout(stdout io.Writer) {
m.Called(stdout)
}

func (m *mockExecRunner) LookPath(file string) (string, error) {
arguments := m.Called(file)
return arguments.String(0), arguments.Error(1)
}

func (m *mockExecRunner) CommandContext(ctx context.Context,
name string, arg ...string) CmdRunner {
arguments := m.Called(ctx, name, arg)
return arguments.Get(0).(CmdRunner)
}

type mockCmd struct {
mock.Mock
}

func (m *mockCmd) Start() error {
arguments := m.Called()
return arguments.Error(0)
}

func (m *mockCmd) Wait() error {
arguments := m.Called()
return arguments.Error(0)
}
59 changes: 59 additions & 0 deletions internal/security/secretstore/execrunner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// Copyright (c) 2020 Intel Corporation
//
// SPDX-License-Identifier: Apache-2.0
//

package secretstore

import (
"context"
"io"
"os"
"os/exec"
)

// CmdRunner is mockable interface for golang's exec.Cmd
type CmdRunner interface {
Start() error
Wait() error
}

// ExecRunner is mockable interface for wrapping os/exec functionality
type ExecRunner interface {
SetStdout(stdout io.Writer)
LookPath(file string) (string, error)
CommandContext(ctx context.Context, name string, arg ...string) CmdRunner
}

type execWrapper struct {
Stdout io.Writer
Stderr io.Writer
}

// NewDefaultExecRunner creates an os/exec wrapper
// that joins subprocesses' stdout and stderr with the caller's
func NewDefaultExecRunner() ExecRunner {
return &execWrapper{
Stdout: os.Stdout,
Stderr: os.Stderr,
}
}

// SetStdout allows overriding of stdout capture (for comsuming password generator output)
func (w *execWrapper) SetStdout(stdout io.Writer) {
w.Stdout = stdout
}

// LookPath wraps os/exec.LookPath
func (w *execWrapper) LookPath(file string) (string, error) {
return exec.LookPath(file)
}

// CommandContext wraps os/exec.CommandContext
func (w *execWrapper) CommandContext(ctx context.Context, name string, arg ...string) CmdRunner {
cmd := exec.CommandContext(ctx, name, arg...)
cmd.Stdout = w.Stdout
cmd.Stderr = w.Stderr
return cmd
}
11 changes: 5 additions & 6 deletions internal/security/secretstore/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ func (b *Bootstrap) BootstrapHandler(ctx context.Context, _ *sync.WaitGroup, _ s
}

//Step 4: Launch token handler
tokenProvider := NewTokenProvider(ctx, lc, ExecWrapper{})
tokenProvider := NewTokenProvider(ctx, lc, NewDefaultExecRunner())
if configuration.SecretService.TokenProvider != "" {
if err := tokenProvider.SetConfiguration(configuration.SecretService); err != nil {
lc.Error(fmt.Sprintf("failed to configure token provider: %s", err.Error()))
Expand All @@ -252,9 +252,8 @@ func (b *Bootstrap) BootstrapHandler(ctx context.Context, _ *sync.WaitGroup, _ s
}

// credential creation
gk := NewGokeyGenerator(rootToken)
lc.Warn("WARNING: The gokey generator is a reference implementation for credential generation and the underlying libraries not been reviewed for cryptographic security. The user is encouraged to perform their own security investigation before deployment.")
cred := NewCred(req, rootToken, gk, configuration.SecretService.GetSecretSvcBaseURL(), lc)
gen := NewPasswordGenerator(lc, configuration.SecretService.PasswordProvider, configuration.SecretService.PasswordProviderArgs)
cred := NewCred(req, rootToken, gen, configuration.SecretService.GetSecretSvcBaseURL(), lc)

// continue credential creation

Expand All @@ -270,7 +269,7 @@ func (b *Bootstrap) BootstrapHandler(ctx context.Context, _ *sync.WaitGroup, _ s
// Redis 5.x only supports a single shared password. When Redis 6 is released, this can be updated
// to a per service password.

redis5Password, err := cred.GeneratePassword("redis5")
redis5Password, err := cred.GeneratePassword(ctx)
if err != nil {
lc.Error("failed to generate redis5 password")
os.Exit(1)
Expand All @@ -283,7 +282,7 @@ func (b *Bootstrap) BootstrapHandler(ctx context.Context, _ *sync.WaitGroup, _ s
for dbname, info := range configuration.Databases {
service := info.Service
// generate credentials
password, err := cred.GeneratePassword(dbname)
password, err := cred.GeneratePassword(ctx)
if err != nil {
lc.Error(fmt.Sprintf("failed to generate credential pair for service %s", service))
os.Exit(1)
Expand Down
52 changes: 26 additions & 26 deletions internal/security/secretstore/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package secretstore

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
Expand All @@ -26,36 +27,34 @@ import (
"github.com/edgexfoundry/edgex-go/internal"

"github.com/edgexfoundry/go-mod-core-contracts/clients/logger"

"github.com/cloudflare/gokey"
)

// CredentialGenerator returns a credential generated with random algorithm for secret store
type CredentialGenerator interface {
Generate(string) (string, error)
}

// GokeyGenerator implements the CredentialGenerator interface using the gokey library
// using tokenPath as the gokey master password and accepting the realm as the argument
// to the Generate method
type GokeyGenerator struct {
masterPassword string
type passwordGenerator struct {
generatorImplementation CredentialGenerator
}

func NewGokeyGenerator(masterPassword string) *GokeyGenerator {
return &GokeyGenerator{masterPassword: masterPassword}
// NewPasswordGenerator wires up a pluggable password generator
// or defaults to a built-in implementation if
// the pluggable configuration is missing
func NewPasswordGenerator(lc logger.LoggingClient, passwordProvider string, passwordProviderArgs []string) CredentialGenerator {
gk := &passwordGenerator{
generatorImplementation: NewDefaultCredentialGenerator(),
}
if passwordProvider != "" {
pp := NewPasswordProvider(lc, NewDefaultExecRunner())
err := pp.SetConfiguration(passwordProvider, passwordProviderArgs)
if err != nil {
lc.Warn(fmt.Sprintf("Could not configure password generator %s: error: %s", passwordProvider, err.Error()))
return gk // fall-back to builtin
}
gk.generatorImplementation = pp
}
return gk
}

func (gk GokeyGenerator) Generate(realm string) (string, error) {
passSpec := gokey.PasswordSpec{
Length: 16,
Upper: 3,
Lower: 3,
Digits: 2,
Special: 1,
AllowedSpecial: "",
}
return gokey.GetPass(gk.masterPassword, realm, nil, &passSpec)
// Generate delegates password generation to underlying implementation
func (gk *passwordGenerator) Generate(ctx context.Context) (string, error) {
return gk.generatorImplementation.Generate(ctx)
}

type CredCollect struct {
Expand Down Expand Up @@ -174,8 +173,9 @@ func (cr *Cred) credPathURL(path string) (string, error) {
return fullURL.String(), nil
}

func (cr *Cred) GeneratePassword(service string) (string, error) {
return cr.generator.Generate(service)
// GeneratePassword is a pass-through to the password generator
func (cr *Cred) GeneratePassword(ctx context.Context) (string, error) {
return cr.generator.Generate(ctx)
}

func (cr *Cred) UploadToStore(pair *UserPasswordPair, path string) error {
Expand Down
36 changes: 36 additions & 0 deletions internal/security/secretstore/password_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// +build linux

//
// Copyright (c) 2019 Intel Corporation
//
// SPDX-License-Identifier: Apache-2.0
//

package secretstore

import (
"context"
"net/http"
"testing"

"github.com/edgexfoundry/go-mod-core-contracts/clients/logger"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGenerateWithAPG(t *testing.T) {
rootToken := "s.Ga5jyNq6kNfRMVQk2LY1j9iu"
mockLogger := logger.MockLogger{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Note: apg only available with gnome-desktop, expected to be missing on server Linux distros
gk := NewPasswordGenerator(mockLogger, "apg", []string{"-a", "1", "-n", "1", "-m", "12", "-x", "64"})
cr := NewCred(&http.Client{}, rootToken, gk, "", logger.MockLogger{})

p1, err := cr.GeneratePassword(ctx)
require.NoError(t, err, "failed to create credential")
p2, err := cr.GeneratePassword(ctx)
require.NoError(t, err, "failed to create credential")
assert.NotEqual(t, p1, p2, "each call to GeneratePassword should return a new password")
}
Loading

0 comments on commit ff532ad

Please sign in to comment.