You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

210 lines
6.8 KiB

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
}