// Package secretcrypto encrypts small secret payloads at rest with AES-256-GCM, // keyed by a random 32-byte key stored in the data directory. The key file and // wire format ("enc:v1:" + base64(nonce||ciphertext)) are shared with the SSH // broker secrets, so a single deployment key protects all stored secrets and // values written by either path decrypt with the other. package secretcrypto import "crypto/aes" import "crypto/cipher" import "crypto/rand" import "encoding/base64" import "errors" import "os" import "path/filepath" import "strings" import "codit/internal/storage" const Prefix string = "enc:v1:" const keyFileName string = "ssh-broker.secret" const keyLen int = 32 // Box encrypts/decrypts payloads using the deployment key under dataDir. type Box struct { dataDir string } func New(dataDir string) *Box { return &Box{dataDir: dataDir} } // Encrypt returns the ciphertext form of payload, or "" for an empty payload. func (b *Box) Encrypt(payload string) (string, error) { var key []byte var block cipher.Block var gcm cipher.AEAD var nonce []byte var ciphertext []byte var raw []byte var err error if strings.TrimSpace(payload) == "" { return "", nil } key, err = b.key() if err != nil { return "", err } block, err = aes.NewCipher(key) if err != nil { return "", err } gcm, err = cipher.NewGCM(block) if err != nil { return "", err } nonce = make([]byte, gcm.NonceSize()) _, err = rand.Read(nonce) if err != nil { return "", err } ciphertext = gcm.Seal(nil, nonce, []byte(payload), nil) raw = append(nonce, ciphertext...) return Prefix + base64.StdEncoding.EncodeToString(raw), nil } // Decrypt reverses Encrypt. A payload without the prefix is returned as-is (so // values stored before encryption, or already-plaintext, pass through). func (b *Box) Decrypt(payload string) (string, error) { var key []byte var encoded string var raw []byte var block cipher.Block var gcm cipher.AEAD var nonce []byte var ciphertext []byte var plaintext []byte var err error if !strings.HasPrefix(payload, Prefix) { return payload, nil } key, err = b.key() if err != nil { return "", err } encoded = strings.TrimSpace(strings.TrimPrefix(payload, Prefix)) raw, err = base64.StdEncoding.DecodeString(encoded) if err != nil { return "", err } block, err = aes.NewCipher(key) if err != nil { return "", err } gcm, err = cipher.NewGCM(block) if err != nil { return "", err } if len(raw) < gcm.NonceSize() { return "", errors.New("invalid encrypted secret") } nonce = raw[:gcm.NonceSize()] ciphertext = raw[gcm.NonceSize():] plaintext, err = gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return "", err } return string(plaintext), nil } // key reads the deployment secret key, generating and persisting it on first use. func (b *Box) key() ([]byte, error) { var path string var data []byte var err error path = filepath.Join(b.dataDir, keyFileName) data, err = os.ReadFile(path) if err == nil { if len(data) != keyLen { return nil, errors.New("invalid secret key length") } return data, nil } if !errors.Is(err, os.ErrNotExist) { return nil, err } data = make([]byte, keyLen) _, err = rand.Read(data) if err != nil { return nil, err } err = os.MkdirAll(b.dataDir, storage.DirPerm) if err != nil { return nil, err } err = os.WriteFile(path, data, storage.SecretFilePerm) if err != nil { return nil, err } return data, nil }