Files

144 lines
3.4 KiB
Go

// 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
}