144 lines
3.4 KiB
Go
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
|
|
}
|