diff --git a/command.go b/command.go index 5bed4818..7d5ba929 100644 --- a/command.go +++ b/command.go @@ -24,6 +24,7 @@ import ( "sort" "strings" + "github.com/OneCloudInc/cobra/decryptor" flag "github.com/spf13/pflag" ) @@ -851,8 +852,8 @@ func (c *Command) ExecuteC() (cmd *Command, err error) { } // RC: Implementation of decryption - use DI to test - decryptor := Gatekeeper{} - flags, err = decryptor.DecryptFlags(flags) + decryptor := decryptor.NewDecryptor() + flags, err = decryptor.DecryptArguments(flags) err = cmd.execute(flags) if err != nil { diff --git a/decryptor.go b/decryptor.go deleted file mode 100644 index 386f276a..00000000 --- a/decryptor.go +++ /dev/null @@ -1,5 +0,0 @@ -package cobra - -type Decryptor interface { - DecryptFlags([]string) ([]string, error) -} diff --git a/decryptor/decryptor.go b/decryptor/decryptor.go new file mode 100644 index 00000000..47c1e020 --- /dev/null +++ b/decryptor/decryptor.go @@ -0,0 +1,31 @@ +package decryptor + +import "os" + +type Decryptor interface { + DecryptArguments([]string) ([]string, error) +} + +func NewDecryptor() Decryptor { + if IsCloudRunner() && ReaperURL() != "" && BizAppAuthToken() != "" && CommandExecutorID() != "" { + return NewReaperDecryptor(ReaperURL(), BizAppAuthToken(), CommandExecutorID()) + } + + return NewNoopDecryptor() +} + +func IsCloudRunner() bool { + return os.Getenv("OC_CLOUDRUNNER_CONFIG") != "" +} + +func ReaperURL() string { + return os.Getenv("REAPER_URL") +} + +func BizAppAuthToken() string { + return os.Getenv("BIZ_APP_AUTH_TOKEN") +} + +func CommandExecutorID() string { + return os.Getenv("OC_COMMAND_EXECUTOR_ID") +} diff --git a/decryptor/noop.go b/decryptor/noop.go new file mode 100644 index 00000000..e454206f --- /dev/null +++ b/decryptor/noop.go @@ -0,0 +1,11 @@ +package decryptor + +type NoopDecryptor struct{} + +func NewNoopDecryptor() Decryptor { + return &NoopDecryptor{} +} + +func (n *NoopDecryptor) DecryptArguments(args []string) ([]string, error) { + return args, nil +} diff --git a/decryptor/reaper.go b/decryptor/reaper.go new file mode 100644 index 00000000..68af2f4e --- /dev/null +++ b/decryptor/reaper.go @@ -0,0 +1,171 @@ +package decryptor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "math/rand" + "net/http" + "regexp" + "time" + + jwt "github.com/dgrijalva/jwt-go" + retryablehttp "github.com/hashicorp/go-retryablehttp" +) + +// ReaperDecryptor will call out to the reaper service to decrypt arguments +// passed into a BizApp. It will use a one-off signing key to handle authorizing +// a request to decrypt arguments associated with a particular CommandExecutor. The +// reaper will return a list of decrypted arguments, given a list of arguments in the +// body of the request +type ReaperDecryptor struct { + BaseURL string + SigningKey []byte + CommandExecutorID string +} + +// Authorization uses a signed nonce, so we really just +// want to sign a string as the token we use for authorization. In order +// for it to be signed correctly, go-jwt requires us to implement a Valid() +// method. +type tokenClaims string + +func (t tokenClaims) Valid() error { + return nil +} + +// +// {"data": {"arguments": ["--arg1=zzz", "--arg2=bbb"]}} +// +type reaperResponse struct { + Data struct { + Arguments []string `json:"arguments"` + } `json:"data"` +} + +const vaultEncryptStart = "OC_ENCRYPTED" +const vaultEncryptEnd = "DETPYRCNE_CO" +const decryptPath = "/runner/decrypt_arguments" + +var vaultRegex = regexp.MustCompile(vaultEncryptStart + "(.*)" + vaultEncryptEnd) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +// NewReaperDecryptor returns a Decryptor implementation that will call out to +// the reaper service to decrypt any encrypted arguments. +func NewReaperDecryptor(url, signingKey, commandExecutorID string) Decryptor { + return &ReaperDecryptor{ + BaseURL: url, + SigningKey: []byte(signingKey), + CommandExecutorID: commandExecutorID, + } +} + +// DecryptArguments replaces any encrypted values with their decrypted values +// using the reaper service. +func (r *ReaperDecryptor) DecryptArguments(args []string) ([]string, error) { + // If no encrypted arguments are found, do not attempt to decrypt + shouldDecrypt := false + for _, arg := range args { + if vaultRegex.MatchString(arg) { + shouldDecrypt = true + break + } + } + + if !shouldDecrypt { + return args, nil + } + + payload := make(map[string]interface{}) + payload["commandExecutorId"] = r.CommandExecutorID + payload["arguments"] = args + payloadBytes, err := json.Marshal(payload) + if err != nil { + return args, fmt.Errorf("error creating payload to send for decryption: %s", err) + } + payloadBody := bytes.NewBuffer(payloadBytes) + + reqURL := fmt.Sprintf("%s%s", r.BaseURL, decryptPath) + retryReq, err := retryablehttp.NewRequest("POST", reqURL, payloadBody) + if err != nil { + return args, fmt.Errorf("error building request to send for decryption: %s", err) + } + + cl := defaultClientWithRetries() + addJWTHeader(retryReq, r.SigningKey) + + resp, err := cl.Do(retryReq) + if err != nil { + return args, fmt.Errorf("error response from decryption service: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + io.Copy(ioutil.Discard, resp.Body) + return args, fmt.Errorf("decryption service responsed with status code %d", resp.StatusCode) + } + + var respObj reaperResponse + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&respObj) + if err != nil { + return args, fmt.Errorf("error parsing response from decryption service: %s", err) + } + + return respObj.Data.Arguments, err +} + +func addJWTHeader(req *retryablehttp.Request, signingKey []byte) error { + nonce := generateNonce() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, nonce) + + signed, err := token.SignedString(signingKey) + if err != nil { + return err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", signed)) + + return err +} + +// random string of 32 bytes to be signed into a JWT - value is not used +func generateNonce() tokenClaims { + b := make([]rune, 32) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + + return tokenClaims(b) +} + +// Using a HTTP client that will automatically retry 5xx errors to ensure that our connection +// is resilient +func defaultClientWithRetries() *retryablehttp.Client { + retryClient := retryablehttp.NewClient() + retryClient.RetryWaitMin = 3 * time.Second + retryClient.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) { + if resp == nil { + return true, err + } + + if resp.StatusCode >= 500 || resp.StatusCode == 429 { + return true, err + } + + if err != nil || ctx.Err() != nil { + return true, err + } + return false, err + } + + retryClient.RequestLogHook = func(l retryablehttp.Logger, req *http.Request, retryCount int) { + *req = *req.Clone(context.TODO()) + } + + return retryClient +} diff --git a/gatekeeper.go b/gatekeeper.go deleted file mode 100644 index 627c6200..00000000 --- a/gatekeeper.go +++ /dev/null @@ -1,39 +0,0 @@ -package cobra - -import ( - "regexp" -) - -const vaultEncryptStart = "OC_ENCRYPTED" -const vaultEncryptEnd = "DETPYRCNE_CO" - -var vaultRegex = regexp.MustCompile(vaultEncryptStart + "(.*)" + vaultEncryptEnd) - -type Gatekeeper struct{} - -func (g *Gatekeeper) DecryptFlags(flags []string) ([]string, error) { - var decryptedFlags []string - - for _, fl := range flags { - keyToDecrypt := extractSecretKey(fl) - if keyToDecrypt == "" { - continue - } else { - // TODO: Gatekeeper client should perform decryption here - // TODO: Remove recursive decryption to the Gatekeeper client - // TODO: Move macro replacement to Gatekeeper package - decryptedFlags = append(decryptedFlags, keyToDecrypt) - } - } - - return decryptedFlags, nil -} - -func extractSecretKey(macro string) string { - matches := vaultRegex.FindStringSubmatch(macro) - if len(matches) == 2 && matches[1] != "" { - secretKey := matches[1] - return secretKey - } - return "" -} diff --git a/go.mod b/go.mod index 8e8e7991..1b7132d6 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,9 @@ go 1.15 require ( github.com/BurntSushi/toml v0.4.1 // indirect github.com/cpuguy83/go-md2man v1.0.8 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/fsnotify/fsnotify v1.4.7 // indirect - github.com/hashicorp/go-retryablehttp v0.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.0 github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 github.com/magiconair/properties v1.8.0 // indirect diff --git a/go.sum b/go.sum index 6c699980..4af59c26 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/cpuguy83/go-md2man v1.0.8 h1:DwoNytLphI8hzS2Af4D0dfaEaiSq2bN05mEm4R6vf8M= github.com/cpuguy83/go-md2man v1.0.8/go.mod h1:N6JayAiVKtlHSnuTCeuLSQVs75hb8q+dYQLjr7cDsKY= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=