feat: implement migration to encrypt sensitive JSON fields in database models

This commit is contained in:
0xJacky
2026-03-16 11:34:10 +08:00
parent 9e41ecf9df
commit 95ab34bbe1
6 changed files with 410 additions and 3 deletions
@@ -0,0 +1,155 @@
package migrate
import (
"bytes"
"encoding/json"
"fmt"
"github.com/0xJacky/Nginx-UI/internal/crypto"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/datatypes"
"gorm.io/gorm"
)
var EncryptSensitiveJSONFields = &gormigrate.Migration{
ID: "20260316000001",
Migrate: func(tx *gorm.DB) error {
if err := migrateDnsCredentialConfig(tx); err != nil {
return err
}
if err := migrateAcmeUserKey(tx); err != nil {
return err
}
if err := migrateCertResource(tx); err != nil {
return err
}
return nil
},
}
type dnsCredentialConfigRow struct {
ID uint64 `gorm:"column:id"`
Config datatypes.JSON `gorm:"column:config"`
}
type acmeUserKeyRow struct {
ID uint64 `gorm:"column:id"`
Key datatypes.JSON `gorm:"column:key"`
}
type certResourceRow struct {
ID uint64 `gorm:"column:id"`
Resource datatypes.JSON `gorm:"column:resource"`
}
func migrateDnsCredentialConfig(tx *gorm.DB) error {
if !tx.Migrator().HasTable("dns_credentials") || !tx.Migrator().HasColumn("dns_credentials", "config") {
return nil
}
var rows []dnsCredentialConfigRow
if err := tx.Table("dns_credentials").Select("id", "config").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
encrypted, changed, err := encryptJSONValueIfNeeded(row.Config)
if err != nil {
return fmt.Errorf("migrate dns_credentials.config for id %d: %w", row.ID, err)
}
if !changed {
continue
}
if err := tx.Table("dns_credentials").
Where("id = ?", row.ID).
Update("config", string(encrypted)).Error; err != nil {
return err
}
}
return nil
}
func migrateAcmeUserKey(tx *gorm.DB) error {
if !tx.Migrator().HasTable("acme_users") || !tx.Migrator().HasColumn("acme_users", "key") {
return nil
}
var rows []acmeUserKeyRow
if err := tx.Table("acme_users").Select("id", "key").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
encrypted, changed, err := encryptJSONValueIfNeeded(row.Key)
if err != nil {
return fmt.Errorf("migrate acme_users.key for id %d: %w", row.ID, err)
}
if !changed {
continue
}
if err := tx.Table("acme_users").
Where("id = ?", row.ID).
Update("key", string(encrypted)).Error; err != nil {
return err
}
}
return nil
}
func migrateCertResource(tx *gorm.DB) error {
if !tx.Migrator().HasTable("certs") || !tx.Migrator().HasColumn("certs", "resource") {
return nil
}
var rows []certResourceRow
if err := tx.Table("certs").Select("id", "resource").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
encrypted, changed, err := encryptJSONValueIfNeeded(row.Resource)
if err != nil {
return fmt.Errorf("migrate certs.resource for id %d: %w", row.ID, err)
}
if !changed {
continue
}
if err := tx.Table("certs").
Where("id = ?", row.ID).
Update("resource", string(encrypted)).Error; err != nil {
return err
}
}
return nil
}
func encryptJSONValueIfNeeded(value []byte) ([]byte, bool, error) {
trimmed := bytes.TrimSpace(value)
if len(trimmed) == 0 {
return nil, false, nil
}
if json.Valid(trimmed) {
encrypted, err := crypto.AesEncrypt(value)
if err != nil {
return nil, false, err
}
return encrypted, true, nil
}
decrypted, err := crypto.AesDecrypt(append([]byte(nil), value...))
if err == nil && json.Valid(bytes.TrimSpace(decrypted)) {
return nil, false, nil
}
return nil, false, fmt.Errorf("value is neither plaintext JSON nor encrypted JSON")
}
@@ -0,0 +1,251 @@
package migrate
import (
"bytes"
"encoding/json"
"fmt"
"math/big"
"testing"
"github.com/0xJacky/Nginx-UI/internal/cert/dns"
"github.com/0xJacky/Nginx-UI/internal/crypto"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/registration"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type legacyDnsCredential struct {
model.Model
Name string `json:"name"`
Config *dns.Config `json:"config,omitempty" gorm:"serializer:json"`
Provider string `json:"provider"`
ProviderCode string `json:"provider_code" gorm:"index"`
}
func (legacyDnsCredential) TableName() string {
return "dns_credentials"
}
type legacyAcmeUser struct {
model.Model
Name string `json:"name"`
Email string `json:"email"`
CADir string `json:"ca_dir"`
Registration registration.Resource `json:"registration" gorm:"serializer:json"`
Key model.PrivateKey `json:"-" gorm:"serializer:json"`
Proxy string `json:"proxy"`
RegisterOnStartup bool `json:"register_on_startup"`
EABKeyID string `json:"eab_key_id"`
EABHMACKey string `json:"eab_hmac_key"`
}
func (legacyAcmeUser) TableName() string {
return "acme_users"
}
type legacyCert struct {
model.Model
Name string `json:"name"`
Filename string `json:"filename"`
Resource *model.CertificateResource `json:"-" gorm:"serializer:json"`
KeyType string `json:"key_type"`
AutoCert int `json:"auto_cert"`
Log string `json:"log"`
Domains []string `json:"domains" gorm:"serializer:json"`
Challenge string `json:"challenge_method"`
}
func (legacyCert) TableName() string {
return "certs"
}
func setupSensitiveFieldTestDB(t *testing.T) *gorm.DB {
t.Helper()
settings.CryptoSettings.Secret = "test-secret"
dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
return db
}
func TestEncryptSensitiveJSONFieldsMigratesLegacyPlaintextData(t *testing.T) {
db := setupSensitiveFieldTestDB(t)
require.NoError(t, db.AutoMigrate(&legacyDnsCredential{}, &legacyAcmeUser{}, &legacyCert{}))
legacyCredential := &legacyDnsCredential{
Name: "production",
Provider: "cloudflare",
Config: &dns.Config{
Name: "Cloudflare",
Code: "cloudflare",
Configuration: &dns.Configuration{
Credentials: map[string]string{
"CF_API_TOKEN": "plaintext-token",
},
},
},
}
require.NoError(t, db.Create(legacyCredential).Error)
legacyUser := &legacyAcmeUser{
Name: "acme",
Email: "admin@example.com",
CADir: "https://acme-v02.api.letsencrypt.org/directory",
Key: model.PrivateKey{
X: big.NewInt(11),
Y: big.NewInt(22),
D: big.NewInt(33),
},
}
require.NoError(t, db.Create(legacyUser).Error)
legacyCert := &legacyCert{
Name: "example.com",
Filename: "example.com",
KeyType: "2048",
Resource: &model.CertificateResource{
Resource: &certificate.Resource{
Domain: "example.com",
CertURL: "https://acme.example/cert",
CertStableURL: "https://acme.example/cert/stable",
},
PrivateKey: []byte("legacy-private-key"),
Certificate: []byte("legacy-certificate"),
IssuerCertificate: []byte("legacy-issuer"),
CSR: []byte("legacy-csr"),
},
}
require.NoError(t, db.Create(legacyCert).Error)
require.NoError(t, EncryptSensitiveJSONFields.Migrate(db))
var credentialRow dnsCredentialConfigRow
require.NoError(t, db.Table("dns_credentials").Select("id", "config").First(&credentialRow, legacyCredential.ID).Error)
assert.False(t, bytes.Contains(credentialRow.Config, []byte("plaintext-token")))
var acmeRow acmeUserKeyRow
require.NoError(t, db.Table("acme_users").Select("id", "key").First(&acmeRow, legacyUser.ID).Error)
assert.False(t, bytes.Equal(bytes.TrimSpace(acmeRow.Key), []byte(`{"X":11,"Y":22,"D":33}`)))
var certRow certResourceRow
require.NoError(t, db.Table("certs").Select("id", "resource").First(&certRow, legacyCert.ID).Error)
assert.False(t, bytes.Contains(certRow.Resource, []byte("legacy-private-key")))
var migratedCredential model.DnsCredential
require.NoError(t, db.First(&migratedCredential, legacyCredential.ID).Error)
require.NotNil(t, migratedCredential.Config)
require.NotNil(t, migratedCredential.Config.Configuration)
assert.Equal(t, "plaintext-token", migratedCredential.Config.Configuration.Credentials["CF_API_TOKEN"])
var migratedAcmeUser model.AcmeUser
require.NoError(t, db.First(&migratedAcmeUser, legacyUser.ID).Error)
assert.Zero(t, migratedAcmeUser.Key.X.Cmp(big.NewInt(11)))
assert.Zero(t, migratedAcmeUser.Key.Y.Cmp(big.NewInt(22)))
assert.Zero(t, migratedAcmeUser.Key.D.Cmp(big.NewInt(33)))
var migratedCert model.Cert
require.NoError(t, db.First(&migratedCert, legacyCert.ID).Error)
require.NotNil(t, migratedCert.Resource)
assert.Equal(t, []byte("legacy-private-key"), migratedCert.Resource.PrivateKey)
assert.Equal(t, []byte("legacy-certificate"), migratedCert.Resource.Certificate)
}
func TestSensitiveModelsPersistEncryptedJSON(t *testing.T) {
db := setupSensitiveFieldTestDB(t)
require.NoError(t, db.AutoMigrate(&model.DnsCredential{}, &model.AcmeUser{}, &model.Cert{}))
credential := &model.DnsCredential{
Name: "production",
Provider: "cloudflare",
Config: &dns.Config{
Name: "Cloudflare",
Code: "cloudflare",
Configuration: &dns.Configuration{
Credentials: map[string]string{
"CF_API_TOKEN": "new-token",
},
},
},
}
require.NoError(t, db.Create(credential).Error)
acmeUser := &model.AcmeUser{
Name: "acme",
Email: "admin@example.com",
CADir: "https://acme-v02.api.letsencrypt.org/directory",
Key: model.PrivateKey{
X: big.NewInt(101),
Y: big.NewInt(202),
D: big.NewInt(303),
},
}
require.NoError(t, db.Create(acmeUser).Error)
certModel := &model.Cert{
Name: "example.com",
Filename: "example.com",
KeyType: "2048",
Resource: &model.CertificateResource{
Resource: &certificate.Resource{
Domain: "example.com",
CertURL: "https://acme.example/cert",
CertStableURL: "https://acme.example/cert/stable",
},
PrivateKey: []byte("new-private-key"),
Certificate: []byte("new-certificate"),
IssuerCertificate: []byte("new-issuer"),
CSR: []byte("new-csr"),
},
}
require.NoError(t, db.Create(certModel).Error)
var credentialRow dnsCredentialConfigRow
require.NoError(t, db.Table("dns_credentials").Select("id", "config").First(&credentialRow, credential.ID).Error)
plainCredential, err := json.Marshal(credential.Config)
require.NoError(t, err)
assert.False(t, bytes.Equal(bytes.TrimSpace(credentialRow.Config), plainCredential))
decryptedCredential, err := crypto.AesDecrypt(append([]byte(nil), credentialRow.Config...))
require.NoError(t, err)
var storedCredential dns.Config
require.NoError(t, json.Unmarshal(decryptedCredential, &storedCredential))
require.NotNil(t, storedCredential.Configuration)
assert.Equal(t, "new-token", storedCredential.Configuration.Credentials["CF_API_TOKEN"])
var acmeRow acmeUserKeyRow
require.NoError(t, db.Table("acme_users").Select("id", "key").First(&acmeRow, acmeUser.ID).Error)
plainKey, err := json.Marshal(acmeUser.Key)
require.NoError(t, err)
assert.False(t, bytes.Equal(bytes.TrimSpace(acmeRow.Key), plainKey))
decryptedKey, err := crypto.AesDecrypt(append([]byte(nil), acmeRow.Key...))
require.NoError(t, err)
var storedKey model.PrivateKey
require.NoError(t, json.Unmarshal(decryptedKey, &storedKey))
assert.Zero(t, storedKey.X.Cmp(big.NewInt(101)))
assert.Zero(t, storedKey.Y.Cmp(big.NewInt(202)))
assert.Zero(t, storedKey.D.Cmp(big.NewInt(303)))
var certRow certResourceRow
require.NoError(t, db.Table("certs").Select("id", "resource").First(&certRow, certModel.ID).Error)
plainResource, err := json.Marshal(certModel.Resource)
require.NoError(t, err)
assert.False(t, bytes.Equal(bytes.TrimSpace(certRow.Resource), plainResource))
decryptedResource, err := crypto.AesDecrypt(append([]byte(nil), certRow.Resource...))
require.NoError(t, err)
var storedResource model.CertificateResource
require.NoError(t, json.Unmarshal(decryptedResource, &storedResource))
assert.Equal(t, []byte("new-private-key"), storedResource.PrivateKey)
assert.Equal(t, []byte("new-certificate"), storedResource.Certificate)
}
+1
View File
@@ -11,6 +11,7 @@ var Migrations = []*gormigrate.Migration{
RenameEnvGroupsToNamespaces,
RenameEnvironmentsToNodes,
AddProviderCodeToDnsCredentials,
EncryptSensitiveJSONFields,
}
var BeforeAutoMigrate = []*gormigrate.Migration{
+1 -1
View File
@@ -23,7 +23,7 @@ type AcmeUser struct {
Email string `json:"email"`
CADir string `json:"ca_dir"`
Registration registration.Resource `json:"registration" gorm:"serializer:json"`
Key PrivateKey `json:"-" gorm:"serializer:json"`
Key PrivateKey `json:"-" gorm:"serializer:json[aes]"`
Proxy string `json:"proxy"`
RegisterOnStartup bool `json:"register_on_startup"`
EABKeyID string `json:"eab_key_id"`
+1 -1
View File
@@ -43,7 +43,7 @@ type Cert struct {
ACMEUser *AcmeUser `json:"acme_user,omitempty"`
KeyType certcrypto.KeyType `json:"key_type"`
Log string `json:"log"`
Resource *CertificateResource `json:"-" gorm:"serializer:json"`
Resource *CertificateResource `json:"-" gorm:"serializer:json[aes]"`
SyncNodeIds []uint64 `json:"sync_node_ids" gorm:"serializer:json"`
MustStaple bool `json:"must_staple"`
LegoDisableCNAMESupport bool `json:"lego_disable_cname_support"`
+1 -1
View File
@@ -7,7 +7,7 @@ import (
type DnsCredential struct {
Model
Name string `json:"name"`
Config *dns.Config `json:"config,omitempty" gorm:"serializer:json"`
Config *dns.Config `json:"config,omitempty" gorm:"serializer:json[aes]"`
Provider string `json:"provider"`
ProviderCode string `json:"provider_code" gorm:"index"`
}