package api import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "strings" "unicode/utf8" "golang.org/x/crypto/pbkdf2" "tera-clone/services/tera-api/internal/domain" ) const ( configEnvelopeFormat = "tera-config-encrypted-v1" configEnvelopeIterations = 210000 configEnvelopeSaltBytes = 16 configEnvelopeIVBytes = 12 configEnvelopeMaxBytes = 1 << 20 ) type encryptedConfigExportRequest struct { Key string `json:"key"` } type encryptedConfigExportResponse struct { Filename string `json:"filename"` Content string `json:"content"` Summary domain.ConfigSummary `json:"summary"` } type encryptedConfigRestoreRequest struct { Key string `json:"key"` Content string `json:"content"` IncludeNetwork bool `json:"include_network"` } type encryptedConfigEnvelope struct { Format string `json:"format"` KDF encryptedConfigKDF `json:"kdf"` Cipher encryptedConfigCipher `json:"cipher"` Ciphertext string `json:"ciphertext"` } type encryptedConfigKDF struct { Name string `json:"name"` Hash string `json:"hash"` Iterations int `json:"iterations"` Salt string `json:"salt"` } type encryptedConfigCipher struct { Name string `json:"name"` KeyBits int `json:"key_bits"` IV string `json:"iv"` } func validateConfigEnvelopeKey(key string) error { length := utf8.RuneCountInString(key) if length < 8 || length > 128 { return errors.New("config file key must be between 8 and 128 characters") } return nil } func encryptConfigEnvelope(snapshot domain.ConfigExport, key string) (string, error) { if err := validateConfigEnvelopeKey(key); err != nil { return "", err } plaintext, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return "", fmt.Errorf("config export serialization failed: %w", err) } plaintext = append(plaintext, '\n') if len(plaintext) > configEnvelopeMaxBytes { return "", errors.New("config export exceeds the 1048576-byte limit") } salt := make([]byte, configEnvelopeSaltBytes) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("config export salt generation failed: %w", err) } iv := make([]byte, configEnvelopeIVBytes) if _, err := rand.Read(iv); err != nil { return "", fmt.Errorf("config export IV generation failed: %w", err) } derived := pbkdf2.Key([]byte(key), salt, configEnvelopeIterations, 32, sha256.New) block, err := aes.NewCipher(derived) if err != nil { return "", fmt.Errorf("config export cipher setup failed: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("config export cipher setup failed: %w", err) } ciphertext := gcm.Seal(nil, iv, plaintext, nil) envelope := encryptedConfigEnvelope{ Format: configEnvelopeFormat, KDF: encryptedConfigKDF{ Name: "PBKDF2", Hash: "SHA-256", Iterations: configEnvelopeIterations, Salt: base64.StdEncoding.EncodeToString(salt), }, Cipher: encryptedConfigCipher{ Name: "AES-GCM", KeyBits: 256, IV: base64.StdEncoding.EncodeToString(iv), }, Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), } content, err := json.MarshalIndent(envelope, "", " ") if err != nil { return "", fmt.Errorf("config export envelope serialization failed: %w", err) } return string(append(content, '\n')), nil } func decryptConfigEnvelope(content, key string) (domain.ConfigExport, error) { var snapshot domain.ConfigExport if err := validateConfigEnvelopeKey(key); err != nil { return snapshot, err } if len(content) < 1 || len(content) > configEnvelopeMaxBytes { return snapshot, errors.New("encrypted config file must be between 1 and 1048576 bytes") } var envelope encryptedConfigEnvelope decoder := json.NewDecoder(strings.NewReader(content)) decoder.DisallowUnknownFields() if err := decoder.Decode(&envelope); err != nil { return snapshot, fmt.Errorf("encrypted config file is not valid JSON: %w", err) } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return snapshot, errors.New("encrypted config file must contain a single JSON value") } if envelope.Format != configEnvelopeFormat || envelope.KDF.Name != "PBKDF2" || envelope.KDF.Hash != "SHA-256" || envelope.Cipher.Name != "AES-GCM" || envelope.Cipher.KeyBits != 256 { return snapshot, errors.New("encrypted config file uses an unsupported format") } if envelope.KDF.Iterations < 100000 || envelope.KDF.Iterations > 1000000 { return snapshot, errors.New("encrypted config file PBKDF2 iterations are outside the allowed range") } salt, err := decodeConfigEnvelopeBase64(envelope.KDF.Salt, "salt") if err != nil { return snapshot, err } iv, err := decodeConfigEnvelopeBase64(envelope.Cipher.IV, "iv") if err != nil { return snapshot, err } ciphertext, err := decodeConfigEnvelopeBase64(envelope.Ciphertext, "ciphertext") if err != nil { return snapshot, err } if len(salt) != configEnvelopeSaltBytes || len(iv) != configEnvelopeIVBytes || len(ciphertext) < 16 || len(ciphertext) > configEnvelopeMaxBytes { return snapshot, errors.New("encrypted config file has invalid field lengths") } derived := pbkdf2.Key([]byte(key), salt, envelope.KDF.Iterations, 32, sha256.New) block, err := aes.NewCipher(derived) if err != nil { return snapshot, fmt.Errorf("config restore cipher setup failed: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return snapshot, fmt.Errorf("config restore cipher setup failed: %w", err) } plaintext, err := gcm.Open(nil, iv, ciphertext, nil) if err != nil { return snapshot, errors.New("config file key is incorrect or the file has been modified") } var raw any if err := json.Unmarshal(plaintext, &raw); err != nil { return snapshot, errors.New("decrypted config file is not valid JSON") } if field, ok := findConfigRestoreSecretField(raw); ok { return snapshot, errors.New("config restore must not include secret material field " + fmt.Sprintf("%q", field)) } plaintextDecoder := json.NewDecoder(strings.NewReader(string(plaintext))) plaintextDecoder.DisallowUnknownFields() if err := plaintextDecoder.Decode(&snapshot); err != nil { return snapshot, fmt.Errorf("decrypted config file is invalid: %w", err) } if err := plaintextDecoder.Decode(&extra); !errors.Is(err, io.EOF) { return snapshot, errors.New("decrypted config file must contain a single JSON value") } return snapshot, nil } func decodeConfigEnvelopeBase64(value, label string) ([]byte, error) { if value == "" || len(value) > configEnvelopeMaxBytes*2 { return nil, fmt.Errorf("encrypted config file %s is invalid", label) } decoded, err := base64.StdEncoding.Strict().DecodeString(value) if err != nil { return nil, fmt.Errorf("encrypted config file %s is not valid Base64", label) } return decoded, nil }