diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index aea9aeeb8c..b5cb9b7a69 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -357,19 +357,25 @@ func (s *BackupService) GetS3Config(ctx context.Context) (*BackupS3Config, error } func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) (*BackupS3Config, error) { - // 如果没提供 secret,保留原有值 + // 如果没提供 secret,保留原有值。loadS3Config 会解密,所以这里拿到的是明文, + // 和调用方新填的 secret 一样,都必须走下面的加密再落库。 if cfg.SecretAccessKey == "" { old, _ := s.loadS3Config(ctx) if old != nil { cfg.SecretAccessKey = old.SecretAccessKey } - } else { + } + + // 加密 SecretAccessKey。这一步不能只在“调用方填了新 secret”时做:表单保存成功 + // 后本函数会清空返回值里的 secret,所以第二次保存(改端点、存定时配置)送上来的 + // secret 是空的,会走上面的分支继承旧值。若那条路径跳过加密,明文就会覆盖掉库里 + // 的密文,而读取侧“兼容未加密旧数据”的回退会把这件事永久掩盖成一条日志。 + if cfg.SecretAccessKey != "" { // 拒绝用自动生成的临时密钥加密:该密钥每次重启都会变化,落库的密文在 // 重启/升级后无法解密(#4524)。与支付、TOTP 的处理保持一致。 if !s.encryptionKeyConfigured { return nil, ErrSecretEncryptionKeyNotConfigured } - // 加密 SecretAccessKey encrypted, err := s.encryptor.Encrypt(cfg.SecretAccessKey) if err != nil { return nil, fmt.Errorf("encrypt secret: %w", err) diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index 876e8243d4..52eb509d17 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -372,6 +372,53 @@ func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) { require.Equal(t, "AKID-NEW", internal.AccessKeyID) } +// 一次不带 secret 的保存(表单第二次提交、改端点、存定时配置)继承的是 loadS3Config +// 解密后的明文。若那条路径跳过加密,明文就会覆盖库里的密文,而读取侧“兼容未加密旧 +// 数据”的回退会把它掩盖成一条日志,功能照常,密钥却是明文落库的。 +func TestBackupService_S3ConfigStaysEncryptedAfterSecondSave(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + + _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{ + Bucket: "my-bucket", + AccessKeyID: "AKID", + SecretAccessKey: "original-secret", + }) + require.NoError(t, err) + + storedSecret := func() string { + raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config) + var stored BackupS3Config + require.NoError(t, json.Unmarshal([]byte(raw), &stored)) + return stored.SecretAccessKey + } + require.Equal(t, "ENC:original-secret", storedSecret()) + + // 第二次保存不带 secret,只改别的字段。 + _, err = svc.UpdateS3Config(context.Background(), BackupS3Config{ + Bucket: "my-bucket", + AccessKeyID: "AKID-NEW", + }) + require.NoError(t, err) + + require.Equal(t, "ENC:original-secret", storedSecret(), + "secret must stay encrypted at rest after a save that inherits it") + require.NotEqual(t, "original-secret", storedSecret(), "secret must never be stored as plaintext") + + // 第三次保存,确认不会反复套壳加密。 + _, err = svc.UpdateS3Config(context.Background(), BackupS3Config{ + Bucket: "my-bucket", + AccessKeyID: "AKID-THIRD", + }) + require.NoError(t, err) + require.Equal(t, "ENC:original-secret", storedSecret(), "secret must not be double-encrypted") + + internal, err := svc.loadS3Config(context.Background()) + require.NoError(t, err) + require.Equal(t, "original-secret", internal.SecretAccessKey) + require.Equal(t, "AKID-THIRD", internal.AccessKeyID) +} + func TestBackupService_UpdateS3Config_RejectsEphemeralKey(t *testing.T) { repo := newMockSettingRepo() svc := newTestBackupServiceEphemeralKey(repo)