Skip to content

Commit

Permalink
Use docker-credential-helper to manage secrets
Browse files Browse the repository at this point in the history
Store service credentials securely in the stores supported by docker:
- https://github.com/docker/docker-credential-helpers#available-programs

Introduces a top-level config property, "secretStore" and additional
command line arguments to manage the stored secrets.

The value of secretStore is used to find a helper command,
`docker-credential-<secretStore>`.

The docker project currently provides 4 store helpers:
- "osxkeychain" (OS X only)
- "secretservice" (Linux only)
- "wincred" (Windows only)
- "pass" (any OS supporting pass, which uses gpg2)

Docker-for-desktop installs the credential helpers above, as well as
"desktop" (docker-credential-desktop).

Generic installation instructions for the helpers:
- https://github.com/docker/docker-credential-helpers#installation

Users could provide additional helpers, the only requirement is that the
helper implements the credential store protocol:
- https://github.com/docker/docker-credential-helpers#development

The credential protocol is open, and new credential stores can be
implemented by any CLI satisfying the protocol:
- https://github.com/docker/docker-credential-helpers#development

The modifications to existing modules is not tested due to lack
of API keys, but demonstrates the unobtrusive changes required to
use the secret store.
  • Loading branch information
sam-github committed May 11, 2020
1 parent d247ec0 commit 3c405da
Show file tree
Hide file tree
Showing 31 changed files with 432 additions and 52 deletions.
224 changes: 224 additions & 0 deletions cfg/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package cfg

import (
"errors"
"fmt"
"runtime"

"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/olebedev/config"
"github.com/wtfutil/wtf/logger"
)

type SecretLoadParams struct {
name string
globalConfig *config.Config
service string

secret *string
}

// Load module secrets.
//
// The credential helpers impose this structure:
//
// SERVICE is mapped to a SECRET and USERNAME
//
// Only SECRET is secret, SERVICE and USERNAME are not, so this
// API doesn't expose USERNAME.
//
// SERVICE was intended to be the URL of an API server, but
// for hosted services that do not have or need a configurable
// API server, its easier to just use the module name as the
// SERVICE:
//
// cfg.ModuleSecret(name, globalConfig, &settings.apiKey).Load()
//
// The user will use the module name as the service, and the API key as
// the secret, for example:
//
// % wtfutil save-secret circleci
// Secret: ...
//
// If a module (such as pihole, jenkins, or github) might have multiple
// instantiations each using a different API service (with its own unique
// API key), then the module should use the API URL to lookup the secret.
// For example, for github:
//
// cfg.ModuleSecret(name, globalConfig, &settings.apiKey).
// Service(settings.baseURL).
// Load()
//
// The user will use the API URL as the service, and the API key as the
// secret, for example, with github configured as:
//
// -- config.yml
// mods:
// github:
// baseURL: "https://github.mycompany.com/api/v3"
// ...
//
// the secret must be saved as:
//
// % wtfutil save-secret https://github.mycompany.com/api/v3
// Secret: ...
//
// If baseURL is not set in the configuration it will be the modules
// default, and the SERVICE will default to the module name, "github",
// and the user must save the secret as:
//
// % wtfutil save-secret github
// Secret: ...
//
// Ideally, the individual module documentation would describe the
// SERVICE name to use to save the secret.
func ModuleSecret(name string, globalConfig *config.Config, secret *string) *SecretLoadParams {
return &SecretLoadParams{
name: name,
globalConfig: globalConfig,
secret: secret,
service: name, // Default the service to the module name
}
}

func (slp *SecretLoadParams) Service(service string) *SecretLoadParams {
if service != "" {
slp.service = service
}
return slp
}

func (slp *SecretLoadParams) Load() {
configureSecret(
slp.globalConfig,
slp.service,
slp.secret,
)
}

type Secret struct {
Service string
Secret string
Username string
Store string
}

func configureSecret(
globalConfig *config.Config,
service string,
secret *string,
) {
if service == "" {
return
}

if secret == nil {
return
}

// Don't overwrite the secret if it was configured with yaml
if *secret != "" {
return
}

cred, err := FetchSecret(globalConfig, service)

if err != nil {
logger.Log(fmt.Sprintf("Loading secret failed: %s", err.Error()))
return
}

if cred == nil {
// No secret store configued.
return
}

if secret != nil && *secret == "" {
*secret = cred.Secret
}
}

// Fetch secret for `service`. Service is customarily a URL, but can be any
// identifier uniquely used by wtf to identify the service, such as the name
// of the module. nil is returned if the secretStore global property is not
// present or the secret is not found in that store.
func FetchSecret(globalConfig *config.Config, service string) (*Secret, error) {
prog := newProgram(globalConfig)

if prog == nil {
// No secret store configured.
return nil, nil
}

cred, err := client.Get(prog.runner, service)

if err != nil {
return nil, fmt.Errorf("get %v from %v: %w", service, prog.store, err)
}

return &Secret{
Service: cred.ServerURL,
Secret: cred.Secret,
Username: cred.Username,
Store: prog.store,
}, nil
}

func StoreSecret(globalConfig *config.Config, secret *Secret) error {
prog := newProgram(globalConfig)

if prog == nil {
return errors.New("cannot store secrets: wtf.secretStore is not configured")
}

cred := &credentials.Credentials{
ServerURL: secret.Service,
Username: secret.Username,
Secret: secret.Secret,
}

// docker-credential requires a username, but it isn't necessary for
// all services. Use a default if a username was not set.
if cred.Username == "" {
cred.Username = "default"
}

err := client.Store(prog.runner, cred)

if err != nil {
return fmt.Errorf("store %v: %w", prog.store, err)
}

return nil
}

type program struct {
store string
runner client.ProgramFunc
}

func newProgram(globalConfig *config.Config) *program {
secretStore := globalConfig.UString("wtf.secretStore", "(none)")

if secretStore == "(none)" {
return nil
}

if secretStore == "" {
switch runtime.GOOS {
case "windows":
secretStore = "winrt"
case "darwin":
secretStore = "osxkeychain"
default:
secretStore = "secretservice"
}

}

return &program{
secretStore,
client.NewShellProgramFunc("docker-credential-" + secretStore),
}
}
71 changes: 71 additions & 0 deletions flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/chzyer/readline"
goFlags "github.com/jessevdk/go-flags"
"github.com/olebedev/config"
"github.com/wtfutil/wtf/cfg"
Expand All @@ -17,10 +19,26 @@ type Flags struct {
Module string `short:"m" long:"module" optional:"yes" description:"Display info about a specific module, i.e.: 'wtfutil -m=todo'"`
Profile bool `short:"p" long:"profile" optional:"yes" description:"Profile application memory usage"`
Version bool `short:"v" long:"version" description:"Show version info"`
// Work-around go-flags misfeatures. If any sub-command is defined
// then `wtf` (no sub-commands, the common usage), is warned about.
Opt struct {
Cmd string `positional-arg-name:"command"`
Args []string `positional-arg-name:"args"`
} `positional-args:"yes"`

hasCustom bool
}

var EXTRA = `
Commands:
save-secret <service>
service Service URL or module name of secret.
Save a secret into the secret store. The secret will be prompted for.
Requires wtf.secretStore to be configured. See individual modules for
information on what service and secret means for their configuration,
not all modules use secrets.
`

// NewFlags creates an instance of Flags
func NewFlags() *Flags {
flags := Flags{}
Expand All @@ -46,6 +64,58 @@ func (flags *Flags) RenderIf(version, date string, config *config.Config) {
fmt.Printf("%s (%s)\n", version, date)
os.Exit(0)
}

if flags.Opt.Cmd == "" {
return
}

switch cmd := flags.Opt.Cmd; cmd {
case "save-secret":
var service, secret string
args := flags.Opt.Args

if len(args) < 1 || args[0] == "" {
fmt.Fprintf(os.Stderr, "save-secret: service required, see `%s --help`\n", os.Args[0])
os.Exit(1)
}

service = args[0]

if len(args) > 1 {
fmt.Fprintf(os.Stderr, "save-secret: too many arguments, see `%s --help`\n", os.Args[0])
os.Exit(1)
}

b, err := readline.Password("Secret: ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
secret = string(b)
secret = strings.TrimSpace(secret)

if secret == "" {
fmt.Fprintf(os.Stderr, "save-secret: secret required, see `%s --help`\n", os.Args[0])
os.Exit(1)
}

err = cfg.StoreSecret(config, &cfg.Secret{
Service: service,
Secret: secret,
Username: "default",
})

if err != nil {
fmt.Fprintf(os.Stderr, "Saving secret for service %q: %s\n", service, err.Error())
os.Exit(1)
}

fmt.Printf("Saved secret for service %q\n", service)
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "Command `%s` is not supported, try `%s --help`\n", cmd, os.Args[0])
os.Exit(1)
}
}

// HasCustomConfig returns TRUE if a config path was passed in, FALSE if one was not
Expand All @@ -68,6 +138,7 @@ func (flags *Flags) Parse() {
parser := goFlags.NewParser(flags, goFlags.Default)
if _, err := parser.Parse(); err != nil {
if flagsErr, ok := err.(*goFlags.Error); ok && flagsErr.Type == goFlags.ErrHelp {
fmt.Println(EXTRA)
os.Exit(0)
}
}
Expand Down
17 changes: 14 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ require (
github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8
github.com/briandowns/openweathermap v0.0.0-20180804155945-5f41b7c9d92d
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/digitalocean/godo v1.35.1
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/docker v1.13.1
github.com/docker/docker-credential-helpers v0.6.3
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.0
Expand All @@ -34,21 +36,29 @@ require (
github.com/hekmon/transmissionrpc v0.0.0-20190525133028-1d589625bacd
github.com/imdario/mergo v0.3.8 // indirect
github.com/jessevdk/go-flags v1.4.0
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.2.0 // indirect
github.com/logrusorgru/aurora v0.0.0-20190803045625-94edacc10f9b
github.com/mattn/go-colorable v0.1.4 // indirect
github.com/microsoft/azure-devops-go-api/azuredevops v0.0.0-20191014190507-26902c1d4325
github.com/mmcdole/gofeed v1.0.0-beta2
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect
github.com/nicklaw5/helix v0.5.8
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olebedev/config v0.0.0-20190528211619-364964f3a8e4
github.com/olekukonko/tablewriter v0.0.4
github.com/onsi/ginkgo v1.10.3 // indirect
github.com/onsi/gomega v1.7.1 // indirect
github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect
github.com/ovh/cds v0.0.0-20200131143542-5e69464c6598
github.com/pborman/uuid v1.2.0 // indirect
github.com/pkg/errors v0.9.1
github.com/pkg/profile v1.4.0
github.com/radovskyb/watcher v1.0.7
github.com/rivo/tview v0.0.0-20200108161608-1316ea7a4b35
github.com/shirou/gopsutil v2.20.4+incompatible
github.com/spf13/cobra v0.0.5 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.6.1 // indirect
github.com/stretchr/testify v1.5.1
github.com/wtfutil/spotigopher v0.0.0-20191127141047-7d8168fe103a
github.com/wtfutil/todoist v0.0.2-0.20191216004217-0ec29ceda61a
Expand All @@ -57,8 +67,9 @@ require (
github.com/zorkian/go-datadog-api v2.29.0+incompatible
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/text v0.3.2
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5 // indirect
google.golang.org/api v0.23.0
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/jarcoal/httpmock.v1 v1.0.0-20181110093347-3be5f16b70eb // indirect
gopkg.in/yaml.v2 v2.2.8
gotest.tools v2.2.0+incompatible
Expand Down
Loading

0 comments on commit 3c405da

Please sign in to comment.