All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add internal/crypto package: AES-256-GCM encrypt/decrypt with migration passthrough for existing plain-text records (no "enc:" prefix = plain text) - Store.NewStoreWithCipher injects cipher; SaveToken/UpdateLocationConfig encrypt cast_api_key before write; GetToken/ListTokens decrypt on read - Add CREDENTIALS_ENCRYPTION_KEY env var (64-hex / 32-byte); warns if unset - Add MongoDB authentication: MONGO_ROOT_USERNAME / MONGO_ROOT_PASSWORD via docker-compose MONGO_INITDB_ROOT_USERNAME/PASSWORD; MONGO_URI now requires credentials in .env.example - Update .env.example with generation instructions for all secrets Co-Authored-By: SideKx <sidekx.ai@sds.dev>
84 lines
2.5 KiB
Go
84 lines
2.5 KiB
Go
package crypto
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
const encPrefix = "enc:"
|
|
|
|
// Cipher encrypts and decrypts short string values using AES-256-GCM.
|
|
// The encrypted format is: "enc:" + hex(nonce + ciphertext + tag).
|
|
//
|
|
// Values that do not start with "enc:" are treated as plain text —
|
|
// this allows safe migration of existing unencrypted records.
|
|
type Cipher struct {
|
|
gcm cipher.AEAD
|
|
}
|
|
|
|
// NewCipher creates a Cipher from a 64-hex-character (32-byte) key.
|
|
func NewCipher(keyHex string) (*Cipher, error) {
|
|
key, err := hex.DecodeString(keyHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypto: invalid hex key: %w", err)
|
|
}
|
|
if len(key) != 32 {
|
|
return nil, fmt.Errorf("crypto: key must be 32 bytes (64 hex chars), got %d bytes", len(key))
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypto: failed to create AES cipher: %w", err)
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypto: failed to create GCM: %w", err)
|
|
}
|
|
return &Cipher{gcm: gcm}, nil
|
|
}
|
|
|
|
// Encrypt encrypts plaintext and returns "enc:<hex(nonce+ciphertext+tag)>".
|
|
// Empty input is returned as-is (no encryption needed for empty/unset fields).
|
|
func (c *Cipher) Encrypt(plaintext string) (string, error) {
|
|
if plaintext == "" {
|
|
return "", nil
|
|
}
|
|
nonce := make([]byte, c.gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", fmt.Errorf("crypto: failed to generate nonce: %w", err)
|
|
}
|
|
ciphertext := c.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
|
return encPrefix + hex.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
// Decrypt decrypts a value produced by Encrypt.
|
|
// If the value does not start with "enc:", it is returned unchanged (plain text migration path).
|
|
func (c *Cipher) Decrypt(value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
if !strings.HasPrefix(value, encPrefix) {
|
|
// Plain text — not yet encrypted; return as-is.
|
|
return value, nil
|
|
}
|
|
data, err := hex.DecodeString(strings.TrimPrefix(value, encPrefix))
|
|
if err != nil {
|
|
return "", fmt.Errorf("crypto: malformed ciphertext hex: %w", err)
|
|
}
|
|
nonceSize := c.gcm.NonceSize()
|
|
if len(data) < nonceSize {
|
|
return "", errors.New("crypto: ciphertext too short")
|
|
}
|
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
|
plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("crypto: decryption failed: %w", err)
|
|
}
|
|
return string(plaintext), nil
|
|
}
|