From da55279b6a2643c639605691ef535d983f87abef Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Mon, 28 Sep 2026 12:19:28 +0800 Subject: [PATCH 1/3] feat: add fixed-key AES helper for secret_password transport Provide a dedicated aes_transport package that decrypts/encrypts with the compile-time SecretKey so DB service APIs can stop accepting plaintext passwords. --- .../pkg/aes_transport/aes_transport.go | 28 ++++++++++++++ .../pkg/aes_transport/aes_transport_test.go | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 pkg/dms-common/pkg/aes_transport/aes_transport.go create mode 100644 pkg/dms-common/pkg/aes_transport/aes_transport_test.go diff --git a/pkg/dms-common/pkg/aes_transport/aes_transport.go b/pkg/dms-common/pkg/aes_transport/aes_transport.go new file mode 100644 index 000000000..4e081f537 --- /dev/null +++ b/pkg/dms-common/pkg/aes_transport/aes_transport.go @@ -0,0 +1,28 @@ +package aes_transport + +import ( + "fmt" + + pkgAes "github.com/actiontech/dms/pkg/dms-common/pkg/aes" +) + +// DecryptSecretPassword decrypts Base64(AES-256-CBC(password)) with the +// compile-time SecretKey. Always uses NewEncryptor(SecretKey); never follows +// ResetAesSecretKey / std runtime key. +func DecryptSecretPassword(secretPassword string) (string, error) { + if secretPassword == "" { + return "", fmt.Errorf("口令密文不完整") + } + enc := pkgAes.NewEncryptor(pkgAes.SecretKey) + plain, err := enc.AesDecrypt(secretPassword) + if err != nil { + return "", fmt.Errorf("口令解密失败") + } + return plain, nil +} + +// EncryptForTest encrypts plaintext with the fixed SecretKey (self-test / curl helpers). +func EncryptForTest(plaintext string) (string, error) { + enc := pkgAes.NewEncryptor(pkgAes.SecretKey) + return enc.AesEncrypt(plaintext) +} diff --git a/pkg/dms-common/pkg/aes_transport/aes_transport_test.go b/pkg/dms-common/pkg/aes_transport/aes_transport_test.go new file mode 100644 index 000000000..d4db264f5 --- /dev/null +++ b/pkg/dms-common/pkg/aes_transport/aes_transport_test.go @@ -0,0 +1,38 @@ +package aes_transport + +import ( + "testing" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + t.Parallel() + plain := "transport-aes-roundtrip" + cipher, err := EncryptForTest(plain) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if cipher == "" || cipher == plain { + t.Fatalf("expected non-empty ciphertext distinct from plaintext") + } + got, err := DecryptSecretPassword(cipher) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if got != plain { + t.Fatalf("got %q want %q", got, plain) + } +} + +func TestDecryptEmpty(t *testing.T) { + t.Parallel() + if _, err := DecryptSecretPassword(""); err == nil { + t.Fatal("expected incomplete ciphertext error") + } +} + +func TestDecryptGarbage(t *testing.T) { + t.Parallel() + if _, err := DecryptSecretPassword("not-valid-base64!!!"); err == nil { + t.Fatal("expected decrypt failure") + } +} From 324f31741b256f3c08cc4b01fe3e67e37735adec Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Mon, 28 Sep 2026 12:19:29 +0800 Subject: [PATCH 2/3] feat: require secret_password on DB service API models Reject plaintext password keys and accept fixed AES secret_password on connectivity check, create, and update request models, with unit coverage. --- api/dms/service/v2/db_service.go | 126 +++++++++++++++++- .../service/v2/db_service_password_test.go | 121 +++++++++++------ pkg/dms-common/api/dms/v1/db_service.go | 47 ++++++- 3 files changed, 249 insertions(+), 45 deletions(-) diff --git a/api/dms/service/v2/db_service.go b/api/dms/service/v2/db_service.go index 1bc4788b5..be85acc3b 100644 --- a/api/dms/service/v2/db_service.go +++ b/api/dms/service/v2/db_service.go @@ -2,6 +2,7 @@ package v2 import ( "bytes" + "encoding/json" base "github.com/actiontech/dms/pkg/dms-common/api/base/v1" dmsCommonV1 "github.com/actiontech/dms/pkg/dms-common/api/dms/v1" @@ -107,9 +108,10 @@ type DBService struct { // DB Service admin user // Required: true User string `json:"user"` - // DB Service admin password - // Required: true - Password string `json:"password" validate:"required"` + // Legacy plaintext password; create path rejects when JSON key present + Password string `json:"password"` + // Transport ciphertext: Base64(AES-256-CBC(password)) with fixed SecretKey + SecretPassword string `json:"secret_password,omitempty"` // DB Service environment tag // Required: true EnvironmentTagUID string `json:"environment_tag_uid" validate:"required"` @@ -130,6 +132,60 @@ type DBService struct { // backup switch // Required: false BackupMaxRows *uint64 `json:"backup_max_rows,omitempty"` + + // passwordKeyPresent is set by UnmarshalJSON when the JSON object contains a "password" key. + passwordKeyPresent bool `json:"-"` +} + +// UnmarshalJSON detects whether the wire JSON contains a plaintext "password" key +// (including empty string), which the create path must reject. +func (d *DBService) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + _, d.passwordKeyPresent = raw["password"] + + type plain struct { + Name string `json:"name"` + DBType string `json:"db_type"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + SecretPassword string `json:"secret_password"` + EnvironmentTagUID string `json:"environment_tag_uid"` + MaintenanceTimes []*dmsCommonV1.MaintenanceTime `json:"maintenance_times"` + AdditionalParams []*dmsCommonV1.AdditionalParam `json:"additional_params"` + Desc string `json:"desc"` + SQLEConfig *dmsCommonV1.SQLEConfig `json:"sqle_config"` + EnableBackup bool `json:"enable_backup"` + BackupMaxRows *uint64 `json:"backup_max_rows,omitempty"` + } + var p plain + if err := json.Unmarshal(data, &p); err != nil { + return err + } + d.Name = p.Name + d.DBType = p.DBType + d.Host = p.Host + d.Port = p.Port + d.User = p.User + d.Password = p.Password + d.SecretPassword = p.SecretPassword + d.EnvironmentTagUID = p.EnvironmentTagUID + d.MaintenanceTimes = p.MaintenanceTimes + d.AdditionalParams = p.AdditionalParams + d.Desc = p.Desc + d.SQLEConfig = p.SQLEConfig + d.EnableBackup = p.EnableBackup + d.BackupMaxRows = p.BackupMaxRows + return nil +} + +// HasPasswordKey reports whether the request JSON included a "password" field. +func (d *DBService) HasPasswordKey() bool { + return d.passwordKeyPresent } // swagger:model AddDBServiceReqV2 @@ -162,8 +218,10 @@ type UpdateDBService struct { // DB Service admin user // Required: true User string `json:"user"` - // DB Service admin password + // Legacy plaintext password; update path rejects when JSON key present Password *string `json:"password"` + // Transport ciphertext: Base64(AES-256-CBC(password)); only when updating password + SecretPassword string `json:"secret_password,omitempty"` // DB Service environment tag // Required: true EnvironmentTagUID string `json:"environment_tag_uid" validate:"required"` @@ -183,6 +241,66 @@ type UpdateDBService struct { // backup switch // Required: false BackupMaxRows *uint64 `json:"backup_max_rows,omitempty"` + + // passwordKeyPresent is set by UnmarshalJSON when the JSON object contains a "password" key. + passwordKeyPresent bool `json:"-"` + // secretPasswordKeyPresent is set when JSON contains "secret_password" (even if empty). + secretPasswordKeyPresent bool `json:"-"` +} + +// UnmarshalJSON detects whether the wire JSON contains a plaintext "password" key +// (including empty string / null), which the update path must reject. +func (u *UpdateDBService) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + _, u.passwordKeyPresent = raw["password"] + _, u.secretPasswordKeyPresent = raw["secret_password"] + + type plain struct { + DBType string `json:"db_type"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Password *string `json:"password"` + SecretPassword string `json:"secret_password"` + EnvironmentTagUID string `json:"environment_tag_uid"` + MaintenanceTimes []*dmsCommonV1.MaintenanceTime `json:"maintenance_times"` + AdditionalParams []*dmsCommonV1.AdditionalParam `json:"additional_params"` + Desc *string `json:"desc"` + SQLEConfig *dmsCommonV1.SQLEConfig `json:"sqle_config"` + EnableBackup bool `json:"enable_backup"` + BackupMaxRows *uint64 `json:"backup_max_rows,omitempty"` + } + var p plain + if err := json.Unmarshal(data, &p); err != nil { + return err + } + u.DBType = p.DBType + u.Host = p.Host + u.Port = p.Port + u.User = p.User + u.Password = p.Password + u.SecretPassword = p.SecretPassword + u.EnvironmentTagUID = p.EnvironmentTagUID + u.MaintenanceTimes = p.MaintenanceTimes + u.AdditionalParams = p.AdditionalParams + u.Desc = p.Desc + u.SQLEConfig = p.SQLEConfig + u.EnableBackup = p.EnableBackup + u.BackupMaxRows = p.BackupMaxRows + return nil +} + +// HasPasswordKey reports whether the request JSON included a "password" field. +func (u *UpdateDBService) HasPasswordKey() bool { + return u.passwordKeyPresent +} + +// HasSecretPasswordKey reports whether the request JSON included a "secret_password" field. +func (u *UpdateDBService) HasSecretPasswordKey() bool { + return u.secretPasswordKeyPresent } // swagger:model ImportDBServicesOfOneProjectReqV2 diff --git a/api/dms/service/v2/db_service_password_test.go b/api/dms/service/v2/db_service_password_test.go index a21b2359e..e2e5a5241 100644 --- a/api/dms/service/v2/db_service_password_test.go +++ b/api/dms/service/v2/db_service_password_test.go @@ -1,49 +1,32 @@ package v2 import ( + "encoding/json" "strings" "testing" utilConf "github.com/actiontech/dms/pkg/dms-common/pkg/config" ) -func TestAddDBServiceReq_EmptyPasswordRejected(t *testing.T) { +func TestAddDBServiceReq_CipherFieldsPassValidation(t *testing.T) { t.Parallel() - base := func(password string) *AddDBServiceReq { - return &AddDBServiceReq{ - ProjectUid: "700300", - DBService: &DBService{ - Name: "gbase8a_empty_pwd", - DBType: "GBase-8a", - Host: "10.186.16.126", - Port: "5258", - User: "root", - Password: password, - EnvironmentTagUID: "2086752861772845056", - MaintenanceTimes: nil, - }, - } + req := &AddDBServiceReq{ + ProjectUid: "700300", + DBService: &DBService{ + Name: "mysql_cipher_create", + DBType: "MySQL", + Host: "10.186.16.126", + Port: "3307", + User: "testuser", + SecretPassword: "cipher-b64", + EnvironmentTagUID: "2086752861772845056", + MaintenanceTimes: nil, + }, + } + if err := utilConf.Validate(req); err != nil { + t.Fatalf("expected cipher create payload to pass Add validation, got: %v", err) } - - t.Run("password_empty_string", func(t *testing.T) { - t.Parallel() - err := utilConf.Validate(base("")) - if err == nil { - t.Fatal("expected empty password to fail Add validation") - } - msg := strings.ToLower(err.Error()) - if !strings.Contains(msg, "password") || !strings.Contains(msg, "required") { - t.Fatalf("expected Password required validation error, got: %v", err) - } - }) - - t.Run("password_non_empty_passes_password_rule", func(t *testing.T) { - t.Parallel() - if err := utilConf.Validate(base("not-empty")); err != nil { - t.Fatalf("expected non-empty password to pass Add validation, got: %v", err) - } - }) } func TestAddDBServiceReq_MissingHostStillRequired(t *testing.T) { @@ -52,12 +35,12 @@ func TestAddDBServiceReq_MissingHostStillRequired(t *testing.T) { req := &AddDBServiceReq{ ProjectUid: "700300", DBService: &DBService{ - Name: "gbase8a_missing_host", - DBType: "GBase-8a", + Name: "mysql_missing_host", + DBType: "MySQL", Host: "", - Port: "5258", - User: "root", - Password: "not-empty", + Port: "3307", + User: "testuser", + SecretPassword: "cipher-b64", EnvironmentTagUID: "2086752861772845056", }, } @@ -71,3 +54,63 @@ func TestAddDBServiceReq_MissingHostStillRequired(t *testing.T) { t.Fatalf("expected Host required validation error, got: %v", err) } } + +func TestDBService_HasPasswordKey(t *testing.T) { + t.Parallel() + + var withKey DBService + if err := json.Unmarshal([]byte(`{"name":"n","db_type":"MySQL","host":"127.0.0.1","port":"3306","user":"u","password":"","secret_password":"x","environment_tag_uid":"1"}`), &withKey); err != nil { + t.Fatal(err) + } + if !withKey.HasPasswordKey() { + t.Fatal("expected password key present") + } + + var withoutKey DBService + if err := json.Unmarshal([]byte(`{"name":"n","db_type":"MySQL","host":"127.0.0.1","port":"3306","user":"u","secret_password":"x","environment_tag_uid":"1"}`), &withoutKey); err != nil { + t.Fatal(err) + } + if withoutKey.HasPasswordKey() { + t.Fatal("expected password key absent") + } +} + +func TestUpdateDBService_HasPasswordKey(t *testing.T) { + t.Parallel() + + var withKey UpdateDBService + if err := json.Unmarshal([]byte(`{"db_type":"MySQL","host":"127.0.0.1","port":"3306","user":"u","password":"","secret_password":"x","environment_tag_uid":"1"}`), &withKey); err != nil { + t.Fatal(err) + } + if !withKey.HasPasswordKey() { + t.Fatal("expected password key present on update") + } + if withKey.SecretPassword != "x" { + t.Fatalf("expected cipher field preserved, got secret=%q", withKey.SecretPassword) + } + if !withKey.HasSecretPasswordKey() { + t.Fatal("expected secret_password key present on update") + } + + var withoutKey UpdateDBService + if err := json.Unmarshal([]byte(`{"db_type":"MySQL","host":"127.0.0.1","port":"3306","user":"u","environment_tag_uid":"1","desc":"keep-pwd"}`), &withoutKey); err != nil { + t.Fatal(err) + } + if withoutKey.HasPasswordKey() { + t.Fatal("expected password key absent on update without password fields") + } + if withoutKey.HasSecretPasswordKey() { + t.Fatal("expected secret_password key absent when omitted") + } + if withoutKey.SecretPassword != "" { + t.Fatal("expected no cipher fields when omitted") + } + + var emptySecret UpdateDBService + if err := json.Unmarshal([]byte(`{"db_type":"MySQL","host":"127.0.0.1","port":"3306","user":"u","secret_password":"","environment_tag_uid":"1"}`), &emptySecret); err != nil { + t.Fatal(err) + } + if !emptySecret.HasSecretPasswordKey() { + t.Fatal("expected empty secret_password placeholder to still set key present") + } +} diff --git a/pkg/dms-common/api/dms/v1/db_service.go b/pkg/dms-common/api/dms/v1/db_service.go index dcabb72ec..8bc3e363e 100644 --- a/pkg/dms-common/api/dms/v1/db_service.go +++ b/pkg/dms-common/api/dms/v1/db_service.go @@ -1,6 +1,8 @@ package v1 import ( + "encoding/json" + base "github.com/actiontech/dms/pkg/dms-common/api/base/v1" "github.com/go-openapi/strfmt" ) @@ -22,13 +24,54 @@ type CheckDbConnectable struct { // Required: true // example: 3306 Port string `json:"port" example:"3306" valid:"required,port"` - // DB Service admin password - // Required: true + // DB Service admin password (legacy plaintext; form connect path rejects when JSON key present) // example: 123456 Password string `json:"password" example:"123456"` + // Transport ciphertext: Base64(AES-256-CBC(password)) with fixed SecretKey + SecretPassword string `json:"secret_password,omitempty"` // DB Service Custom connection parameters // Required: false AdditionalParams []*AdditionalParam `json:"additional_params" from:"additional_params"` + + // passwordKeyPresent is set by UnmarshalJSON when the JSON object contains a "password" key. + passwordKeyPresent bool `json:"-"` +} + +// UnmarshalJSON detects whether the wire JSON contains a plaintext "password" key +// (including empty string), which the form connect path must reject. +func (c *CheckDbConnectable) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + _, c.passwordKeyPresent = raw["password"] + + type plain struct { + DBType string `json:"db_type"` + User string `json:"user"` + Host string `json:"host"` + Port string `json:"port"` + Password string `json:"password"` + SecretPassword string `json:"secret_password"` + AdditionalParams []*AdditionalParam `json:"additional_params"` + } + var p plain + if err := json.Unmarshal(data, &p); err != nil { + return err + } + c.DBType = p.DBType + c.User = p.User + c.Host = p.Host + c.Port = p.Port + c.Password = p.Password + c.SecretPassword = p.SecretPassword + c.AdditionalParams = p.AdditionalParams + return nil +} + +// HasPasswordKey reports whether the request JSON included a "password" field. +func (c *CheckDbConnectable) HasPasswordKey() bool { + return c.passwordKeyPresent } type AdditionalParam struct { From 32dfd614b74b77c2383d289834846857cab5d9e7 Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Mon, 28 Sep 2026 12:19:29 +0800 Subject: [PATCH 3/3] feat: decrypt secret_password in DB service handlers without logging plaintext Decrypt transport ciphertext into locals, clear request password fields before logging, and pass plaintext only into usecases for check/create/update. --- internal/dms/service/db_service.go | 59 ++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/internal/dms/service/db_service.go b/internal/dms/service/db_service.go index e72e75737..8e7398bee 100644 --- a/internal/dms/service/db_service.go +++ b/internal/dms/service/db_service.go @@ -12,6 +12,7 @@ import ( dmsCommonV1 "github.com/actiontech/dms/pkg/dms-common/api/dms/v1" dmsCommonV2 "github.com/actiontech/dms/pkg/dms-common/api/dms/v2" pkgAes "github.com/actiontech/dms/pkg/dms-common/pkg/aes" + "github.com/actiontech/dms/pkg/dms-common/pkg/aes_transport" "github.com/actiontech/dms/pkg/params" "github.com/actiontech/dms/pkg/periods" "github.com/go-openapi/strfmt" @@ -31,6 +32,25 @@ func (d *DMSService) DelDBService(ctx context.Context, req *dmsV1.DelDBServiceRe } func (d *DMSService) UpdateDBService(ctx context.Context, req *dmsV2.UpdateDBServiceReq, currentUserUid string) (err error) { + if req.DBService.HasPasswordKey() { + return fmt.Errorf("禁止明文口令传输") + } + + var password *string + if req.DBService.HasSecretPasswordKey() { + if req.DBService.SecretPassword == "" { + return fmt.Errorf("口令密文不完整") + } + plainPassword, err := aes_transport.DecryptSecretPassword(req.DBService.SecretPassword) + if err != nil { + return err + } + password = &plainPassword + } + // Clear request password fields before logging / usecase so %v never prints secrets. + req.DBService.SecretPassword = "" + req.DBService.Password = nil + d.log.Infof("UpdateDBService.req=%v", req) defer func() { d.log.Infof("UpdateDBService.req=%v;error=%v", req, err) @@ -65,7 +85,7 @@ func (d *DMSService) UpdateDBService(ctx context.Context, req *dmsV2.UpdateDBSer Host: req.DBService.Host, Port: req.DBService.Port, User: req.DBService.User, - Password: req.DBService.Password, + Password: password, EnvironmentTagUID: req.DBService.EnvironmentTagUID, EnableBackup: req.DBService.EnableBackup, BackupMaxRows: autoChooseBackupMaxRows(req.DBService.EnableBackup, req.DBService.BackupMaxRows), @@ -92,10 +112,26 @@ func (d *DMSService) UpdateDBService(ctx context.Context, req *dmsV2.UpdateDBSer } func (d *DMSService) CheckDBServiceIsConnectable(ctx context.Context, req *dmsV1.CheckDBServiceIsConnectableReq) (reply *dmsV1.CheckDBServiceIsConnectableReply, err error) { - if err := normalizeCheckDbConnectable(&req.DBService); err != nil { + if req.DBService.HasPasswordKey() { + return nil, fmt.Errorf("禁止明文口令传输") + } + if req.DBService.SecretPassword == "" { + return nil, fmt.Errorf("口令密文不完整") + } + plainPassword, err := aes_transport.DecryptSecretPassword(req.DBService.SecretPassword) + if err != nil { return nil, err } - results, err := d.DBServiceUsecase.IsConnectable(ctx, req.DBService) + // Keep plaintext in a local copy only; clear req so it cannot leak via %v logging. + req.DBService.SecretPassword = "" + req.DBService.Password = "" + checkArgs := req.DBService + checkArgs.Password = plainPassword + + if err := normalizeCheckDbConnectable(&checkArgs); err != nil { + return nil, err + } + results, err := d.DBServiceUsecase.IsConnectable(ctx, checkArgs) if err != nil { d.log.Errorf("IsConnectable err: %v", err) @@ -319,6 +355,21 @@ func (d *DMSService) AddDBService(ctx context.Context, req *dmsV1.AddDBServiceRe } func (d *DMSService) AddDBServiceV2(ctx context.Context, req *dmsV2.AddDBServiceReq, currentUserUid string) (reply *dmsV1.AddDBServiceReply, err error) { + if req.DBService.HasPasswordKey() { + return nil, fmt.Errorf("禁止明文口令传输") + } + if req.DBService.SecretPassword == "" { + return nil, fmt.Errorf("口令密文不完整") + } + plainPassword, err := aes_transport.DecryptSecretPassword(req.DBService.SecretPassword) + if err != nil { + return nil, err + } + // Keep plaintext in a local variable only; clear req before logging / usecase. + req.DBService.SecretPassword = "" + req.DBService.Password = "" + password := plainPassword + d.log.Infof("AddDBServices.req=%v", req) defer func() { d.log.Infof("AddDBServices.req=%v;reply=%v;error=%v", req, reply, err) @@ -354,7 +405,7 @@ func (d *DMSService) AddDBServiceV2(ctx context.Context, req *dmsV2.AddDBService Host: req.DBService.Host, Port: req.DBService.Port, User: req.DBService.User, - Password: &req.DBService.Password, + Password: &password, EnvironmentTagUID: req.DBService.EnvironmentTagUID, MaintenancePeriod: d.convertMaintenanceTimeToPeriod(req.DBService.MaintenanceTimes), ProjectUID: req.ProjectUid,