Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docker.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions .github/workflows/functional-tests.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions control-plane/cmd/af/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ func loadConfig(configFile string) (*config.Config, error) {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}

config.ApplyEnvOverrides(&cfg)

// Apply sensible defaults for user experience
if cfg.AgentField.Port == 0 {
cfg.AgentField.Port = 8080
Expand Down
2 changes: 2 additions & 0 deletions control-plane/config/agentfield.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions control-plane/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type UIConfig struct {
// AgentFieldConfig holds the core AgentField server configuration.
type AgentFieldConfig struct {
Port int `yaml:"port"`
Registration RegistrationConfig `yaml:"registration" mapstructure:"registration"`
NodeHealth NodeHealthConfig `yaml:"node_health" mapstructure:"node_health"`
LLMHealth LLMHealthConfig `yaml:"llm_health" mapstructure:"llm_health"`
ExecutionCleanup ExecutionCleanupConfig `yaml:"execution_cleanup" mapstructure:"execution_cleanup"`
Expand All @@ -43,6 +44,11 @@ type AgentFieldConfig struct {
ExecutionLogs ExecutionLogsConfig `yaml:"execution_logs" mapstructure:"execution_logs"`
}

// RegistrationConfig governs validation of agent-supplied registration endpoints.
type RegistrationConfig struct {
ServerlessDiscoveryAllowedHosts []string `yaml:"serverless_discovery_allowed_hosts" mapstructure:"serverless_discovery_allowed_hosts"`
}

// NodeLogProxyConfig limits the control plane proxy to agent process logs (NDJSON).
type NodeLogProxyConfig struct {
ConnectTimeout time.Duration `yaml:"connect_timeout" mapstructure:"connect_timeout"`
Expand Down Expand Up @@ -361,6 +367,17 @@ func ApplyEnvOverrides(cfg *Config) {
cfg.API.Auth.APIKey = apiKey
}

if val := os.Getenv("AGENTFIELD_REGISTRATION_SERVERLESS_DISCOVERY_ALLOWED_HOSTS"); val != "" {
parts := strings.Split(val, ",")
cfg.AgentField.Registration.ServerlessDiscoveryAllowedHosts = cfg.AgentField.Registration.ServerlessDiscoveryAllowedHosts[:0]
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
cfg.AgentField.Registration.ServerlessDiscoveryAllowedHosts = append(cfg.AgentField.Registration.ServerlessDiscoveryAllowedHosts, trimmed)
}
}
}

// Node health monitoring overrides
if val := os.Getenv("AGENTFIELD_HEALTH_CHECK_INTERVAL"); val != "" {
if d, err := time.ParseDuration(val); err == nil {
Expand Down
165 changes: 87 additions & 78 deletions control-plane/internal/encryption/encryption.go
Original file line number Diff line number Diff line change
@@ -1,154 +1,163 @@
package encryption

import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strings"

"golang.org/x/crypto/pbkdf2"
)

const (
encryptionStringVersion = "v2"
encryptionBinaryMagic = "AFENC2"
encryptionSaltSize = 16
encryptionKeySize = 32
encryptionPBKDF2Rounds = 600000
)

// EncryptionService provides encryption and decryption for sensitive configuration values
type EncryptionService struct {
key []byte
passphrase []byte
}

// NewEncryptionService creates a new encryption service with a derived key
// NewEncryptionService creates a new encryption service with a PBKDF2-hardened passphrase.
func NewEncryptionService(passphrase string) *EncryptionService {
// Derive a 32-byte key from the passphrase using SHA-256
hash := sha256.Sum256([]byte(passphrase))
return &EncryptionService{
key: hash[:],
passphrase: []byte(passphrase),
}
}

// Encrypt encrypts a plaintext string and returns a base64-encoded ciphertext
func (es *EncryptionService) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
func (es *EncryptionService) deriveKey(salt []byte) []byte {
return pbkdf2.Key(es.passphrase, salt, encryptionPBKDF2Rounds, encryptionKeySize, sha256.New)
}

func (es *EncryptionService) encryptRaw(plaintext []byte) ([]byte, error) {
if len(plaintext) == 0 {
return nil, nil
}

// Create AES cipher
block, err := aes.NewCipher(es.key)
salt := make([]byte, encryptionSaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}

block, err := aes.NewCipher(es.deriveKey(salt))
if err != nil {
return "", fmt.Errorf("failed to create AES cipher: %w", err)
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}

// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
return nil, fmt.Errorf("failed to create GCM: %w", err)
}

// Generate a random nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}

// Encrypt the plaintext
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)

// Return base64-encoded ciphertext
return base64.StdEncoding.EncodeToString(ciphertext), nil
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
encoded := make([]byte, 0, len(encryptionBinaryMagic)+len(salt)+len(nonce)+len(ciphertext))
encoded = append(encoded, encryptionBinaryMagic...)
encoded = append(encoded, salt...)
encoded = append(encoded, nonce...)
encoded = append(encoded, ciphertext...)
return encoded, nil
}

// Decrypt decrypts a base64-encoded ciphertext and returns the plaintext
func (es *EncryptionService) Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
func (es *EncryptionService) decryptRaw(ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 {
return nil, nil
}

// Decode base64
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode base64: %w", err)
if !bytes.HasPrefix(ciphertext, []byte(encryptionBinaryMagic)) {
return nil, fmt.Errorf("unsupported legacy ciphertext format")
}

// Create AES cipher
block, err := aes.NewCipher(es.key)
data := ciphertext[len(encryptionBinaryMagic):]
if len(data) < encryptionSaltSize {
return nil, fmt.Errorf("ciphertext too short")
}

salt, encryptedData := data[:encryptionSaltSize], data[encryptionSaltSize:]

block, err := aes.NewCipher(es.deriveKey(salt))
if err != nil {
return "", fmt.Errorf("failed to create AES cipher: %w", err)
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}

// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
return nil, fmt.Errorf("failed to create GCM: %w", err)
}

// Check minimum length
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
if len(encryptedData) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}

// Extract nonce and encrypted data
nonce, encryptedData := data[:nonceSize], data[nonceSize:]

// Decrypt
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
nonce, sealed := encryptedData[:nonceSize], encryptedData[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
return nil, fmt.Errorf("failed to decrypt: %w", err)
}

return string(plaintext), nil
return plaintext, nil
}

// EncryptBytes encrypts raw bytes and returns the ciphertext as bytes (nonce prepended).
func (es *EncryptionService) EncryptBytes(plaintext []byte) ([]byte, error) {
if len(plaintext) == 0 {
return nil, nil
// Encrypt encrypts a plaintext string and returns a versioned, base64-encoded ciphertext.
func (es *EncryptionService) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}

block, err := aes.NewCipher(es.key)
encoded, err := es.encryptRaw([]byte(plaintext))
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
return "", err
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
return encryptionStringVersion + ":" + base64.StdEncoding.EncodeToString(encoded), nil
}

nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
// Decrypt decrypts a base64-encoded ciphertext and returns the plaintext
func (es *EncryptionService) Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}

return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

// DecryptBytes decrypts ciphertext bytes (nonce prepended) and returns the plaintext bytes.
func (es *EncryptionService) DecryptBytes(ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 {
return nil, nil
encoded := ciphertext
if strings.HasPrefix(ciphertext, encryptionStringVersion+":") {
encoded = strings.TrimPrefix(ciphertext, encryptionStringVersion+":")
}

block, err := aes.NewCipher(es.key)
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
return "", fmt.Errorf("failed to decode base64: %w", err)
}

gcm, err := cipher.NewGCM(block)
plaintext, err := es.decryptRaw(data)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
return "", err
}

nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
return string(plaintext), nil
}

nonce, encryptedData := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt: %w", err)
}
// EncryptBytes encrypts raw bytes and returns the versioned ciphertext bytes.
func (es *EncryptionService) EncryptBytes(plaintext []byte) ([]byte, error) {
return es.encryptRaw(plaintext)
}

return plaintext, nil
// DecryptBytes decrypts versioned ciphertext bytes and returns the plaintext bytes.
func (es *EncryptionService) DecryptBytes(ciphertext []byte) ([]byte, error) {
return es.decryptRaw(ciphertext)
}

// EncryptConfigurationValues encrypts sensitive values in a configuration map
Expand Down
17 changes: 16 additions & 1 deletion control-plane/internal/encryption/encryption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func TestEncryptionService_EncryptDecrypt_Roundtrip(t *testing.T) {
decrypted, err := service.Decrypt(ciphertext)
require.NoError(t, err)
require.Equal(t, plaintext, decrypted)
require.Contains(t, ciphertext, "v2:")
}

func TestEncryptionService_EncryptDecrypt_EmptyString(t *testing.T) {
Expand Down Expand Up @@ -121,7 +122,7 @@ func TestEncryptionService_EncryptDecrypt_TooShort(t *testing.T) {

_, err := service.Decrypt(shortCiphertext)
require.Error(t, err)
require.Contains(t, err.Error(), "ciphertext too short")
require.Contains(t, err.Error(), "unsupported legacy ciphertext format")
}

func TestEncryptionService_EncryptConfigurationValues(t *testing.T) {
Expand Down Expand Up @@ -283,6 +284,20 @@ func TestEncryptionService_LongPlaintext(t *testing.T) {
require.Equal(t, string(longPlaintext), decrypted)
}

func TestEncryptionService_EncryptBytesDecryptBytes_Roundtrip(t *testing.T) {
service := NewEncryptionService("test-passphrase")

plaintext := []byte("sensitive-bytes")
ciphertext, err := service.EncryptBytes(plaintext)
require.NoError(t, err)
require.NotEmpty(t, ciphertext)
require.NotEqual(t, plaintext, ciphertext)

decrypted, err := service.DecryptBytes(ciphertext)
require.NoError(t, err)
require.Equal(t, plaintext, decrypted)
}

func TestEncryptionService_SpecialCharacters(t *testing.T) {
passphrase := "test-passphrase"
service := NewEncryptionService(passphrase)
Expand Down
Loading
Loading