在本地和kubernetes环境中管理Golang应用程序的秘密

239 阅读8分钟

这个想法源于我以前工作的地方处理应用程序秘密的方式。在我看来,这种管理方式很好,但需要一些改进。我将向你展示一个应用程序和一个工程师如何与应用程序的秘密互动。

正如我们现在所知道的,应用程序的秘密通常存在于应用程序的存储库中。它往往是一个.env 文件或纯文本形式的环境变量,由于明显的原因,这并不理想。在这个例子中,我们将受益于加密/解密,我将使用一个Golang应用程序。这比以前的方法更安全,因为入侵者必须窃取JSON文件、SSH文件、你的应用程序、找出相关环境变量的工作方式等等。要处理的事情太多了,而且更难猜到。

优点

  • 将应用程序的秘密保存在一个集中的服务中,有助于简化管理和维护。

  • 应用程序变得无状态,并且没有配置文件。

  • 秘密总是以加密的形式保存在我们的机器中。

  • 不再有散落在应用程序库中的配置文件。

  • CI/CD管道不需要管理应用程序的秘密,因为当应用程序启动时,它将自己处理一切。

  • Docker镜像中不再有作为环境文件或数值的硬编码的秘密。

  • 如果你需要引入更多的环境或新的键值对,可以轻松扩展。

  • 每个环境的所有应用配置的结构/模式都完全相同。

它是如何工作的

用户将使用secret push ... 命令将新的秘密推送到远程存储,并使用secret pull ... 命令将秘密拉到本地环境。这里重要的一点是,拉取秘密只适用于local 环境,因为你不希望不断冲击远程服务,因为这将是缓慢的,而且成本高昂。然而,所有其他环境将只使用远程服务。

远程秘密存储是我们要存储秘密的地方。我们将使用AWS SSM服务,但它可以是任何其他服务,如Vault、Azure、GCP等。当用户提取秘密时,它们将被保存在本地机器的JSON文件中,使用SSH密钥来加密值。当应用程序运行时,该文件将被读取、解密并映射到结构中。

如前所述,"本地 "和 "其他 "环境的工作方式不同。本地 "是工程师在他们的PC上运行应用程序的地方,而 "其他 "是Kubernetes运行你的应用程序的地方(qa、staging、sandbox、prod...)。两者都有 "绿色 "和 "红色 "部分,如下所述。

本地

  • 绿色--工程师使用secret push ... 命令将秘密推送到远程存储,并使用secret pull ... ,将秘密拉到本地机器。**注意:**这些将几乎不被使用,因为我们并不总是与秘密互动。

  • 红色--工程师运行应用程序,应用程序从本地文件系统中读取一次JSON秘密文件,并使用名为secret 的自定义结构标签映射到一个结构。

其他

结合绿色和红色,这只有一种工作方式,非常简单。当应用程序首次运行时,它会与远程存储对话,以提取机密文件并映射到结构中。

需要知道的事情

首先,我对一些数值进行了硬编码,并使一些代码变得 "有主见",但你可以重构它以改善它。原因是,我们在这里的重点不是要提供最漂亮的代码!而是要让你了解如何使用这些代码。它是关于给你一个如何处理秘密的想法。

  • 当使用push 命令在终端输入时,出于安全原因,秘密值不会被打印到终端。

  • 多行秘密值应作为单行值输入,使用\n 作为换行符。例如,SSH密钥。

  • 目前,push 命令一次处理单个键值对。理想情况下,它应该处理多个。

  • 本地存储的格式是:User_Home_Directory/.Organisation_Name/secret/Service_Environment/Service_Name.json 。例如:/Users/you/.inanzzz/secret/test/app.json

注意

如果你想让你的应用程序在本地环境下运行时总是从JSON文件中读取秘密,你可以修改secret.go 文件以摆脱意见代码。我建议你这样做,因为你不希望在本地环境下工作时依赖第三方服务。也许不适合生产秘密!

秘密存储库

├── aws
│   ├── aws.go
│   └── kms.go
├── crypto
│   └── crypto.go
├── go.mod
├── main.go
└── secret
    ├── command.go
    └── secret.go

文件

aws.go
package aws

import (
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
)

func NewSession() (*session.Session, error) {
	return session.NewSessionWithOptions(session.Options{
		Profile: "localstack",
		Config: aws.Config{
			Region:   aws.String("eu-west-1"),
			Endpoint: aws.String("http://localhost:4566"),
		},
	})
}
kms.go
package aws

import (
	"context"
	"fmt"
	"strings"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/ssm"
)

type KMS struct {
	client *ssm.SSM
}

func NewKMS(ses *session.Session) *KMS {
	return &KMS{
		client: ssm.New(ses),
	}
}

// Load gets multiple secret values from the remote location using their path prefix.
func (k *KMS) Load(ctx context.Context, path string) (map[string]string, error) {
	res, err := k.client.GetParametersByPathWithContext(ctx, &ssm.GetParametersByPathInput{
		Path: aws.String(path),
	})
	if err != nil {
		return nil, err
	}

	sec := make(map[string]string, len(res.Parameters))
	for _, param := range res.Parameters {
		sec[strings.TrimPrefix(*param.Name, path)] = *param.Value
	}

	return sec, nil
}

// Insert puts a new secret key-value pair to the remote location using its path prefix.
func (k *KMS) Insert(ctx context.Context, path, key, val string) error {
	_, err := k.client.PutParameterWithContext(ctx, &ssm.PutParameterInput{
		Name:      aws.String(fmt.Sprintf("%s/%s", path, key)),
		Tier:      aws.String(ssm.ParameterTierStandard),
		Type:      aws.String(ssm.ParameterTypeString),
		Value:     aws.String(val),
		Overwrite: aws.Bool(true),
	})
	if err != nil {
		return err
	}

	return nil
}
crypto.go
package crypto

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/hex"
	"encoding/pem"
	"fmt"
	"golang.org/x/crypto/ssh"
	"os"
)

type Crypto struct{}

// Encrypt encrypts plain value using SSH public key.
func (c Crypto) Encrypt(plain string) (string, error) {
	sshKey, err := c.sshKey("id_rsa.pub")
	if err != nil {
		return "", fmt.Errorf("get ssh key: %w", err)
	}

	pubKey, _, _, _, err := ssh.ParseAuthorizedKey(sshKey)
	if err != nil {
		return "", fmt.Errorf("parse authorised key: %w", err)
	}

	key := pubKey.(ssh.CryptoPublicKey).CryptoPublicKey().(*rsa.PublicKey)

	val, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, key, []byte(plain), nil)
	if err != nil {
		return "", fmt.Errorf("encrypt: %w", err)
	}

	return hex.EncodeToString(val), nil
}

// Decrypt decrypts previously encrypted value using SSH private key.
func (c Crypto) Decrypt(encoded string) (string, error) {
	sshKey, err := c.sshKey("id_rsa")
	if err != nil {
		return "", fmt.Errorf("get ssh key: %w", err)
	}

	decoded, err := hex.DecodeString(encoded)
	if err != nil {
		return "", fmt.Errorf("decode data: %w", err)
	}

	pemBlock, _ := pem.Decode(sshKey)
	key, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
	if err != nil {
		return "", fmt.Errorf("parse private key: %w", err)
	}

	val, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, key, decoded, nil)
	if err != nil {
		return "", fmt.Errorf("decode: %w", err)
	}

	return string(val), nil
}

// sshKey returns either a public or private SSH key.
func (c Crypto) sshKey(file string) ([]byte, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil, err
	}

	key, err := os.ReadFile(fmt.Sprintf("%s/.ssh/%s", home, file))
	if err != nil {
		return nil, err
	}

	return key, nil
}
command.go
package secret

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/you/secret/aws"
	"github.com/you/secret/crypto"
)

type Command struct {
	Crypto  crypto.Crypto
	Storage *aws.KMS

	Action      string
	Environment string
	Service     string
	SecretKey   string
	SecretVal   string
}

// Pull talks to remote secret storage to fetch secrets, talks to crypto
// service to encrypt the values, constructs a JSON file using key-value pair
// and saves it to local path.
func (c Command) Pull(ctx context.Context) error {
	if c.Environment == "" {
		return fmt.Errorf("service environment variable is required")
	}

	list, err := c.Storage.Load(ctx, fmt.Sprintf("/%s/%s/", c.Environment, c.Service))
	if err != nil {
		return fmt.Errorf("load secrets: %w", err)
	}

	for k, v := range list {
		val, err := c.Crypto.Encrypt(v)
		if err != nil {
			return fmt.Errorf("encrypt secret value: %w", err)
		}
		list[k] = val
	}

	data, err := json.MarshalIndent(list, "", "    ")
	if err != nil {
		return fmt.Errorf("marshal secrets: %w", err)
	}

	home, err := os.UserHomeDir()
	if err != nil {
		return fmt.Errorf("get home directory: %w", err)
	}

	dir := fmt.Sprintf("%s/.inanzzz/secret/%s", home, c.Environment)
	if err := os.MkdirAll(dir, 0755); err != nil {
		return fmt.Errorf("create secret directory: %w", err)
	}

	file := fmt.Sprintf("%s/.inanzzz/secret/%s/%s.json", home, c.Environment, c.Service)
	if err := os.WriteFile(file, data, 0755); err != nil {
		return fmt.Errorf("create secret file: %w", err)
	}

	return nil
}

// Push pushes a secret key-value pair to remote secret storage.
func (c Command) Push(ctx context.Context) error {
	if c.Environment == "" {
		return fmt.Errorf("service environment variable is required")
	}

	err := c.Storage.Insert(ctx, fmt.Sprintf("/%s/%s/", c.Environment, c.Service), c.SecretKey, c.SecretVal)
	if err != nil {
		return fmt.Errorf("insert secret: %w", err)
	}

	return nil
}
secret.go
package secret

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"reflect"

	"github.com/you/secret/aws"
	"github.com/you/secret/crypto"
)

type Secret struct {
	Crypto  crypto.Crypto
	Storage *aws.KMS

	Service     string
	Environment string
}

func (s Secret) Load(ctx context.Context, cfg interface{}) error {
	switch s.Environment {
	case "":
		return fmt.Errorf("service environment variable is required")
	case "local":
		return s.fromFile(cfg)
	}

	return s.fromClient(ctx, cfg)
}

func (s Secret) fromFile(cfg interface{}) error {
	home, err := os.UserHomeDir()
	if err != nil {
		return err
	}

	file, err := ioutil.ReadFile(fmt.Sprintf("%s/.inanzzz/secret/local/%s.json", home, s.Service))
	if err != nil {
		return err
	}

	data := make(map[string]string)

	if err := json.Unmarshal(file, &data); err != nil {
		return err
	}

	return s.bind(cfg, data, true)
}

func (s Secret) fromClient(ctx context.Context, cfg interface{}) error {
	data, err := s.Storage.Load(ctx, fmt.Sprintf("/%s/%s/", s.Environment, s.Service))
	if err != nil {
		return fmt.Errorf("load secrets: %w", err)
	}

	return s.bind(cfg, data, false)
}

func (s Secret) bind(cfg interface{}, data map[string]string, dec bool) error {
	configSource := reflect.ValueOf(cfg)
	if configSource.Kind() != reflect.Ptr {
		return fmt.Errorf("config must be a pointer")
	}
	configSource = configSource.Elem()
	if configSource.Kind() != reflect.Struct {
		return fmt.Errorf("config must be a struct")
	}

	configType := configSource.Type()

	for i := 0; i < configSource.NumField(); i++ {
		fieldTag, ok := configType.Field(i).Tag.Lookup("secret")
		if !ok {
			continue
		}

		fieldName := configType.Field(i).Name
		fieldValue := configSource.FieldByName(fieldName)
		if !fieldValue.IsValid() {
			continue
		}
		if !fieldValue.CanSet() {
			continue
		}

		enc, ok := data[fieldTag]
		if !ok {
			continue
		}

		if !dec {
			fieldValue.SetString(enc)
			continue
		}

		val, err := s.Crypto.Decrypt(enc)
		if err != nil {
			return fmt.Errorf("decrypt %s: %w", fieldTag, err)
		}
		fieldValue.SetString(val)
	}

	return nil
}
主程序
package main

import (
	"bufio"
	"context"
	"flag"
	"fmt"
	"golang.org/x/term"
	"log"
	"os"
	"strings"

	"github.com/you/secret/aws"
	"github.com/you/secret/crypto"
	"github.com/you/secret/secret"
)

func main() {
	ctx := context.Background()

	// Initialise AWS session.
	awsSes, err := aws.NewSession()
	if err != nil {
		log.Fatalln(err)
	}

	// Initialise AWS KMS service which is remote secret storage.
	scrClient := aws.NewKMS(awsSes)

	// Initialise secret command handler.
	scrCmd := secret.Command{
		Crypto:      crypto.Crypto{},
		Storage:     scrClient,
		Environment: os.Getenv("SERVICE_ENV"),
	}

	// Capture user input and update command handler.
	flag.Parse()
	scrCmd.Action = flag.Arg(0)
	if flag.Arg(1) == "-svc" || flag.Arg(1) == "--svc" {
		scrCmd.Service = flag.Arg(2)
	}

	switch scrCmd.Action {
	case "pull":
		if err := scrCmd.Pull(ctx); err != nil {
			log.Fatalln(err)
		}
		fmt.Println("Pulled!")

	case "push":
		var err error

		scrCmd.SecretKey, err = key()
		if err != nil {
			log.Fatalln(err)
		}

		scrCmd.SecretVal, err = val()
		if err != nil {
			log.Fatalln(err)
		}

		if err := scrCmd.Push(ctx); err != nil {
			log.Fatalln(err)
		}
		fmt.Println("Pushed!")

	default:
		fmt.Println("Invalid action!")
	}
}

// key prompts user to enter secret key which is visible on terminal.
func key() (string, error) {
	fmt.Printf("Key   > ")

	rdr := bufio.NewReader(os.Stdin)
	for {
		answer, err := rdr.ReadString('\n')
		if err != nil {
			return "", err
		}

		return strings.TrimSuffix(answer, "\n"), nil
	}
}

// val prompts user to enter secret value which is not visible on terminal.
func val() (string, error) {
	fmt.Printf("Value > ")

	raw, err := term.MakeRaw(0)
	if err != nil {
		return "", err
	}
	defer term.Restore(0, raw)

	var (
		prompt string
		answer string
	)

	trm := term.NewTerminal(os.Stdin, prompt)
	for {
		char, err := trm.ReadPassword(prompt)
		if err != nil {
			return "", err
		}
		answer += char

		if char == "" || char == answer {
			return answer, nil
		}
	}
}
使用方法

运行$ go install github.com/you/secret@latest 命令,将二进制文件安装到你的机器上,这样你就可以在任何地方开始使用$ secret push/pull... 命令。

应用程序库

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	"github.com/you/secret/aws"
	"github.com/you/secret/crypto"
	"github.com/you/secret/secret"
)

type Config struct {
	ServiceAddress string `json:"service_address"`

	MySQLPass  string `secret:"mysql_pass"`
	PrivateKey string `secret:"private_key"`
}

func main() {
	ctx := context.Background()

	cfg := Config{
		ServiceAddress: "http://localhost/",
		MySQLPass:      "pass",
		PrivateKey:     "",
	}

	// Initialise AWS session.
	awsSes, err := aws.NewSession()
	if err != nil {
		log.Fatalln(err)
	}

	// Initialise AWS KMS service which is remote secret storage.
	scrClient := aws.NewKMS(awsSes)

	scr := secret.Secret{
		Crypto:      crypto.Crypto{},
		Storage:     scrClient,
		Service:     "api",
		Environment: os.Getenv("SERVICE_ENV"),
	}

	if err := scr.Load(ctx, &cfg); err != nil {
		log.Fatalln(err)
	}

	fmt.Println("----")
	fmt.Println("Service Address:", cfg.ServiceAddress)
	fmt.Println("MySQL Password:", cfg.MySQLPass)
	fmt.Println("Private Key:")
	fmt.Println(strings.ReplaceAll(cfg.PrivateKey, `\n`, "\n"))
}

如果你想在你的应用程序中简化使用,你可以在secret资源库中硬编码一些变量和依赖性。我把这个问题留给你,但你的最终代码将看起来像这样:

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	"github.com/you/secret/secret"
)

type Config struct {
	ServiceAddress string `json:"service_address"`

	MySQLPass  string `secret:"mysql_pass"`
	PrivateKey string `secret:"private_key"`
}

func main() {
	cfg := Config{
		ServiceAddress: "http://localhost/",
		MySQLPass:      "pass",
		PrivateKey:     "",
	}

	if err := secret.Load(context.Background(), &cfg, "api"); err != nil {
		log.Fatalln(err)
	}

	// ...
}

测试

让我们假装我们正在处理 "prod "环境的秘密,而我们的应用程序被称为 "api":

$ env | grep SERVICE_ENV
SERVICE_ENV=prod

将秘密推送到AWS KMS:

// Value is 123123
$ secret push --svc api
Key   > mysql_pass
Value >
Pushed!

// Value is -----BEGIN RSA PRIVATE KEY-----\nvpGGduSuXsp++jPUTvQxAZMRX/Y0Q==\n-----END RSA PRIVATE KEY-----
$ secret push --svc api
Key   > private_key
Value >
Pushed!

验证它们的存在:

$ aws --profile localstack --endpoint-url http://localhost:4566 ssm get-parameters-by-path --path "/prod/api/"
{
    "Parameters": [
        {
            "Name": "/prod/api/mysql_pass",
            "Type": "String",
            "Value": "123123",
        },
        {
            "Name": "/prod/api/private_key",
            "Type": "String",
            "Value": "-----BEGIN RSA PRIVATE KEY-----\nvpGGduSuXsp++jPUTvQxAZMRX/Y0Q==\n-----END RSA PRIVATE KEY-----",
        }
    ]
}

鉴于我们的环境被设置为prod ,应用程序将运行,因为如前所述,只有 "本地 "环境从本地存储中读取:

api$ go run main.go
----
Service Address: http://localhost/
MySQL Password: 123123
Private Key:
-----BEGIN RSA PRIVATE KEY-----
vpGGduSuXsp++jPUTvQxAZMRX/Y0Q==
-----END RSA PRIVATE KEY-----

现在让我们把环境改为local ,然后运行应用程序,这将会失败:

api$ export SERVICE_ENV=local

api$ go run main.go
2022/03/05 18:44:32 open /Users/you/.inanzzz/secret/local/api.json: no such file or directory
exit status 1

为了使其工作,我们需要先推送秘密,然后拉出,再重试。我假设你已经推送了

$ secret pull --svc api
Pulled!
$ cat $HOME/.inanzzz/secret/local/api.json
{
    "mysql_pass": "a64c9135...",
    "private_key": "24501aa8e3f3..."
}
api$ go run main.go
----
Service Address: http://localhost/
MySQL Password: 111111
Private Key:
-----BEGIN RSA PRIVATE KEY-----
vpGGduSuXsp++jPUTvQxAZMRX/Y0Q==
-----END RSA PRIVATE KEY-----