23/02/2022 - AWS, GO, KUBERNETES
Uygulama sırlarını nasıl ele alabileceğimiz fikrinin kaynağı, daha önce çalıştığım yerdir. Yönetilme şekli iyiydi ama bence bazı iyileştirmelere ihtiyaç vardı. Size bir uygulamanın ve bir mühendisin uygulama sırlarıyla nasıl etkileşime gireceğini göstereceğim.
Hepimizin bildiği gibi, uygulama sırları genellikle uygulamaların deposunda bulunur. Bu, düz metin halinde olan bir .env
dosyası veya ortam değişkenleri olur ki, belirli nedenlerle ideal olmayan bir durumdur. Bu örnekte şifreleme/şifre çözme işleminden faydalanacağız, ayrıca ben bir Golang uygulaması kullanacağım. Bu, eski yönteme göre daha güvenlidir, çünkü davetsiz misafirin JSON dosyalarını, SSH dosyalarını, uygulamanızı çalması, ilgili ortam değişkenlerinin nasıl çalıştığını çözmesi gerekir. Zahmetli ve tahmin etmesi biraz zor.
Kullanıcı, yeni sırları uzak depolamaya göndermek için secret push ...
komutunu ve sırları yerel ortama çekmek için secret pull ...
komutunu kullanır. Buradaki önemli olan nokta, sırları çekmenin yalnızca local
ortam için geçerli olmasıdır, çünkü uzak servisi kullanmaya devam etmek istemezsiniz, bu hem yavaş hem de maliyetli olacaktır. Ancak, diğer tüm ortamlar yalnızca uzak hizmeti kullanacaktır.
Uzak ortamdaki depo, sırları depolayacağımız yerdir. AWS SSM hizmetini kullanacağız, ancak bu, Vault, Azure, GCP vb. gibi başka herhangi bir hizmet olabilir. Kullanıcı sırları aldığında, değerleri şifrelemek için SSH anahtarları kullanılarak bir JSON dosyasında yerel makineye kaydedilir. Uygulama çalıştığında, bu dosya okunacak, şifresi çözülecek ve struct ile eşleştirilecektir.
Çalışma şekli "local" (yerel) ve "other" (diğer) ortamlar için farklılık gösterir. "local", mühendislerin bilgisayarlarında uygulamaları çalıştırdığı, "other" ise Kubernetes'in uygulamalarınızı çalıştırdığı yerdir (qa, staging, sandbox, prod ...). Her ikisi de aşağıda açıklandığı gibi "yeşil" ve "kırmızı" bölümlere sahiptir.
secret push ...
komutunu ve gizli bilgileri yerel makineye çekmek için secret pull ...
komutunu kullanır. Not: Her zaman sırlarla etkileşime girmediğimiz için bunlar çok az kullanılacaktır.secret
adlı özel bir yapı etiketini kullanarak, yerel dosya sisteminden bir yapıya eşlemek için JSON dosyasından gizli dizileri okur.Yeşil ve kırmızıyı birleştiren bunun tek bir çalışma şekli vardır ve çok basittir. Uygulama ilk kez çalıştırıldığında, bir yapıyla eşlenecek sırları çekmek için uzak depolamayla konuşur.
Her şeyden önce, bazı değerleri sabit bir şekilde kodladım ve bazı kodları "sert görüşlü" hale getirdim, ancak onu iyileştirmek için yeniden düzenleyebilirsiniz. Buradaki amacımız en güzel kodu yaratmak değil! Size sırları nasıl ele alacağınız konusunda bir fikir vermekle ilgilidir.
push
komutu kullanılarak terminale yazıldığında, güvenlik nedeniyle terminale gizli değer yazdırılmaz.\n
kullanılarak tek satırlı değerler olarak girilmelidir. Örneğin, SSH anahtarları.push
komutu bir seferde tek bir anahtar/değer çiftini işler. İdeal olarak, çoklu işlemelidir.User_Home_Directory/.Organisation_Name/secret/Service_Environment/Service_Name.json
şeklindedir. Örneğin /Users/you/.inanzzz/secret/test/app.json
Uygulamanızın yerel ortamda çalışırken JSON dosyasındaki gizli dizileri her zaman okumasını istiyorsanız, "sert görüşlü" koddan kurtulmak için secret.go
dosyasını değiştirebilirsiniz. Bunu size öneriyorum çünkü yerel ortamda çalışırken üçüncü taraf bir hizmete bağımlı olmak istemezsiniz. Belki prodüksiyon sırları için değil!
├── aws
│ ├── aws.go
│ └── kms.go
├── crypto
│ └── crypto.go
├── go.mod
├── main.go
└── secret
├── command.go
└── secret.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"),
},
})
}
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
}
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
}
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
}
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
}
}
}
$ secret push/pull...
komutlarını kullanmaya başlayabilmeniz için, $ go install github.com/you/secret@latest
komutunu çalıştırarak binary dosyasını makinenize yükleyin. Bu herhangi bir yerden olabilir.
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"))
}
Uygulamalarınızda kullanımı basitleştirmek istiyorsanız, bazı değişkenleri ve bağımlılıkları gizli depoda kodlayabilirsiniz. Bunu size bırakıyorum ama kodunuz şu şekilde görünecek.
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)
}
// ...
}
Diyelim ki "prod" ortam sırları ile uğraşıyoruz ve uygulamamızın adı "api".
$ env | grep SERVICE_ENV
SERVICE_ENV=prod
AWS KMS'e sırları gönderme.
// 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!
Varlıklarını doğrulayın.
$ 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-----",
}
]
}
Ortamımız prod
olarak ayarlandığında uygulama çalışacaktır çünkü daha önce belirtildiği gibi, sadece "local" ortam yerel depolamadan sırları okur.
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-----
Şimdi ortamı local
olarak değiştirelim ve uygulamayı çalıştıralım. Bu başarısız olacak.
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
Bu işi yapmak için önce sırları iletmemiz, çekmemiz ve sonra tekrar denememiz gerekiyor. Çoktan ilettiğinizi varsayıyorum!
$ 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-----