mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
feat: implement migration to encrypt sensitive JSON fields in database models
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ var Migrations = []*gormigrate.Migration{
|
||||
RenameEnvGroupsToNamespaces,
|
||||
RenameEnvironmentsToNodes,
|
||||
AddProviderCodeToDnsCredentials,
|
||||
EncryptSensitiveJSONFields,
|
||||
}
|
||||
|
||||
var BeforeAutoMigrate = []*gormigrate.Migration{
|
||||
|
||||
+1
-1
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user