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.
209 lines
6.8 KiB
209 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
|
|
}
|
|
|