🐛 fix(connection): 隔离保存连接中的扩展密钥

- 将 JVM 凭据与敏感连接参数拆入密钥存储
- 公共和编辑连接视图仅返回脱敏值与存在标记
- 完善连接包迁移、显式清除和密钥往返测试
This commit is contained in:
Syngnat
2026-08-08 19:10:51 +08:00
parent bc02a4c614
commit 23c40c36c4
14 changed files with 681 additions and 29 deletions

View File

@@ -155,6 +155,26 @@ func encryptSecretBundle(appKey []byte, bundle connectionSecretBundle, connectio
if err != nil {
return connectionSecretBundle{}, err
}
encrypted.JVMJMXPassword, err = encryptSecretField(appKey, bundle.JVMJMXPassword, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
encrypted.JVMEndpointAPIKey, err = encryptSecretField(appKey, bundle.JVMEndpointAPIKey, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
encrypted.JVMAgentAPIKey, err = encryptSecretField(appKey, bundle.JVMAgentAPIKey, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
encrypted.JVMDiagnosticAPIKey, err = encryptSecretField(appKey, bundle.JVMDiagnosticAPIKey, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
encrypted.SensitiveParams, err = encryptSecretField(appKey, bundle.SensitiveParams, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
return encrypted, nil
}
@@ -199,6 +219,26 @@ func decryptSecretBundle(appKey []byte, bundle connectionSecretBundle, connectio
if err != nil {
return connectionSecretBundle{}, err
}
decrypted.JVMJMXPassword, err = decryptSecretField(appKey, bundle.JVMJMXPassword, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
decrypted.JVMEndpointAPIKey, err = decryptSecretField(appKey, bundle.JVMEndpointAPIKey, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
decrypted.JVMAgentAPIKey, err = decryptSecretField(appKey, bundle.JVMAgentAPIKey, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
decrypted.JVMDiagnosticAPIKey, err = decryptSecretField(appKey, bundle.JVMDiagnosticAPIKey, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
decrypted.SensitiveParams, err = decryptSecretField(appKey, bundle.SensitiveParams, connectionID)
if err != nil {
return connectionSecretBundle{}, err
}
return decrypted, nil
}

View File

@@ -98,6 +98,11 @@ func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) {
RedisSentinelPassword: "sentinel-secret",
OpaqueURI: "postgres://user:pass@db.local/app",
OpaqueDSN: "server=db.local;password=secret",
JVMJMXPassword: "jmx-secret",
JVMEndpointAPIKey: "endpoint-key",
JVMAgentAPIKey: "agent-key",
JVMDiagnosticAPIKey: "diagnostic-key",
SensitiveParams: "accessToken=param-secret",
}
encrypted, err := encryptSecretBundle(appKey, plain, "conn-1")
@@ -115,6 +120,11 @@ func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) {
"redisSentinelPassword": encrypted.RedisSentinelPassword,
"opaqueURI": encrypted.OpaqueURI,
"opaqueDSN": encrypted.OpaqueDSN,
"jvmJMXPassword": encrypted.JVMJMXPassword,
"jvmEndpointAPIKey": encrypted.JVMEndpointAPIKey,
"jvmAgentAPIKey": encrypted.JVMAgentAPIKey,
"jvmDiagnosticAPIKey": encrypted.JVMDiagnosticAPIKey,
"sensitiveParams": encrypted.SensitiveParams,
} {
if value == "" {
t.Fatalf("expected encrypted %s field to be populated", name)
@@ -125,7 +135,9 @@ func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) {
if value == plain.Password || value == plain.SSHPassword || value == plain.ProxyPassword ||
value == plain.HTTPTunnelPassword || value == plain.MySQLReplicaPassword || value == plain.MongoReplicaPassword ||
value == plain.RedisSentinelPassword ||
value == plain.OpaqueURI || value == plain.OpaqueDSN {
value == plain.OpaqueURI || value == plain.OpaqueDSN || value == plain.JVMJMXPassword ||
value == plain.JVMEndpointAPIKey || value == plain.JVMAgentAPIKey ||
value == plain.JVMDiagnosticAPIKey || value == plain.SensitiveParams {
t.Fatalf("expected encrypted %s field to differ from plaintext", name)
}
}

View File

@@ -226,6 +226,11 @@ func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connecti
config.RedisSentinelPassword = secrets.RedisSentinelPassword
config.URI = secrets.OpaqueURI
config.DSN = secrets.OpaqueDSN
config.JVM.JMX.Password = secrets.JVMJMXPassword
config.JVM.Endpoint.APIKey = secrets.JVMEndpointAPIKey
config.JVM.Agent.APIKey = secrets.JVMAgentAPIKey
config.JVM.Diagnostic.APIKey = secrets.JVMDiagnosticAPIKey
config.ConnectionParams = mergeConnectionParams(config.ConnectionParams, secrets.SensitiveParams)
return connection.SavedConnectionInput{
ID: id,
@@ -249,6 +254,11 @@ func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connecti
ClearRedisSentinelPassword: strings.TrimSpace(secrets.RedisSentinelPassword) == "",
ClearOpaqueURI: strings.TrimSpace(secrets.OpaqueURI) == "",
ClearOpaqueDSN: strings.TrimSpace(secrets.OpaqueDSN) == "",
ClearJVMJMXPassword: strings.TrimSpace(secrets.JVMJMXPassword) == "",
ClearJVMEndpointAPIKey: strings.TrimSpace(secrets.JVMEndpointAPIKey) == "",
ClearJVMAgentAPIKey: strings.TrimSpace(secrets.JVMAgentAPIKey) == "",
ClearJVMDiagnosticAPIKey: strings.TrimSpace(secrets.JVMDiagnosticAPIKey) == "",
ClearSensitiveParams: strings.TrimSpace(secrets.SensitiveParams) == "",
}
}

View File

@@ -0,0 +1,81 @@
package app
import (
"net/url"
"strings"
)
// partitionConnectionParams keeps ordinary driver options in connection metadata while
// routing credential-like query parameters to the connection secret bundle. If the
// value cannot be parsed losslessly as a query string, it is treated as secret in its
// entirety: exposing less metadata is safer than accidentally serializing a token.
func partitionConnectionParams(raw string) (public string, sensitive string) {
normalized := strings.TrimSpace(raw)
if normalized == "" {
return "", ""
}
normalized = strings.TrimPrefix(normalized, "?")
values, err := url.ParseQuery(normalized)
if err != nil {
return "", raw
}
publicValues := make(url.Values)
sensitiveValues := make(url.Values)
for key, items := range values {
target := publicValues
if isSensitiveConnectionParamKey(key) {
target = sensitiveValues
}
for _, item := range items {
target.Add(key, item)
}
}
return publicValues.Encode(), sensitiveValues.Encode()
}
func isSensitiveConnectionParamKey(key string) bool {
compact := strings.ToLower(strings.TrimSpace(key))
compact = strings.NewReplacer("_", "", "-", "", ".", "", " ", "").Replace(compact)
if compact == "" {
return false
}
switch compact {
case "auth", "authorization", "credential", "credentials", "cookie", "sessioncookie", "signature", "sig":
return true
}
return compact == "pwd" ||
strings.HasSuffix(compact, "password") ||
strings.HasSuffix(compact, "passwd") ||
strings.HasSuffix(compact, "token") ||
strings.HasSuffix(compact, "apikey") ||
strings.HasSuffix(compact, "secret") ||
strings.HasSuffix(compact, "secretkey") ||
strings.HasSuffix(compact, "privatekey") ||
strings.HasSuffix(compact, "accesskey")
}
func mergeConnectionParams(public string, sensitive string) string {
public = strings.TrimSpace(public)
sensitive = strings.TrimSpace(sensitive)
if public == "" {
return sensitive
}
if sensitive == "" {
return public
}
publicValues, publicErr := url.ParseQuery(strings.TrimPrefix(public, "?"))
sensitiveValues, sensitiveErr := url.ParseQuery(strings.TrimPrefix(sensitive, "?"))
if publicErr != nil || sensitiveErr != nil {
return strings.TrimSuffix(public, "&") + "&" + strings.TrimPrefix(sensitive, "&")
}
for key, items := range sensitiveValues {
publicValues.Del(key)
for _, item := range items {
publicValues.Add(key, item)
}
}
return publicValues.Encode()
}

View File

@@ -61,7 +61,15 @@ func connectionConfigCarriesInlineSecrets(config connection.ConnectionConfig) bo
strings.TrimSpace(config.MongoReplicaPassword) != "" ||
strings.TrimSpace(config.RedisSentinelPassword) != "" ||
strings.TrimSpace(config.URI) != "" ||
strings.TrimSpace(config.DSN) != ""
strings.TrimSpace(config.DSN) != "" ||
strings.TrimSpace(config.JVM.JMX.Password) != "" ||
strings.TrimSpace(config.JVM.Endpoint.APIKey) != "" ||
strings.TrimSpace(config.JVM.Agent.APIKey) != "" ||
strings.TrimSpace(config.JVM.Diagnostic.APIKey) != "" ||
func() bool {
_, sensitive := partitionConnectionParams(config.ConnectionParams)
return strings.TrimSpace(sensitive) != ""
}()
}
func mergeInlineConnectionSecrets(base connection.ConnectionConfig, inline connection.ConnectionConfig) connection.ConnectionConfig {
@@ -93,6 +101,24 @@ func mergeInlineConnectionSecrets(base connection.ConnectionConfig, inline conne
if strings.TrimSpace(inline.DSN) != "" {
merged.DSN = inline.DSN
}
if strings.TrimSpace(inline.JVM.JMX.Password) != "" {
merged.JVM.JMX.Password = inline.JVM.JMX.Password
}
if strings.TrimSpace(inline.JVM.Endpoint.APIKey) != "" {
merged.JVM.Endpoint.APIKey = inline.JVM.Endpoint.APIKey
}
if strings.TrimSpace(inline.JVM.Agent.APIKey) != "" {
merged.JVM.Agent.APIKey = inline.JVM.Agent.APIKey
}
if strings.TrimSpace(inline.JVM.Diagnostic.APIKey) != "" {
merged.JVM.Diagnostic.APIKey = inline.JVM.Diagnostic.APIKey
}
publicParams, sensitiveParams := partitionConnectionParams(inline.ConnectionParams)
if strings.TrimSpace(sensitiveParams) != "" {
merged.ConnectionParams = mergeConnectionParams(merged.ConnectionParams, sensitiveParams)
} else if strings.TrimSpace(merged.ConnectionParams) == "" {
merged.ConnectionParams = publicParams
}
return merged
}
@@ -157,5 +183,18 @@ func mergeConnectionSecretBundleIntoConfig(config connection.ConnectionConfig, b
if strings.TrimSpace(merged.DSN) == "" {
merged.DSN = bundle.OpaqueDSN
}
if strings.TrimSpace(merged.JVM.JMX.Password) == "" {
merged.JVM.JMX.Password = bundle.JVMJMXPassword
}
if strings.TrimSpace(merged.JVM.Endpoint.APIKey) == "" {
merged.JVM.Endpoint.APIKey = bundle.JVMEndpointAPIKey
}
if strings.TrimSpace(merged.JVM.Agent.APIKey) == "" {
merged.JVM.Agent.APIKey = bundle.JVMAgentAPIKey
}
if strings.TrimSpace(merged.JVM.Diagnostic.APIKey) == "" {
merged.JVM.Diagnostic.APIKey = bundle.JVMDiagnosticAPIKey
}
merged.ConnectionParams = mergeConnectionParams(merged.ConnectionParams, bundle.SensitiveParams)
return merged
}

View File

@@ -103,35 +103,39 @@ func migrateSavedConnectionSecrets(repo *savedConnectionRepository, legacy legac
func (r *savedConnectionRepository) resolveMigrationConnectionBundle(view connection.SavedConnectionView, legacy legacyWebKitVisibleConfig) (connectionSecretBundle, bool, error) {
inline := extractConnectionSecretBundle(view.Config)
if inline.hasAny() {
return inline, true, nil
}
stored, ok, err := r.dailySecrets().GetConnection(view.ID)
if err != nil {
return connectionSecretBundle{}, false, err
}
if ok {
return fromDailyConnectionBundle(stored), true, nil
return mergeConnectionSecretBundles(fromDailyConnectionBundle(stored), inline), true, nil
}
legacyBundle := findLegacyConnectionSecretBundle(legacy.Connections, view.ID)
if legacyBundle.hasAny() {
return legacyBundle, true, nil
return mergeConnectionSecretBundles(legacyBundle, inline), true, nil
}
if !shouldReadLegacySecretStoreForDailySecrets() {
if inline.hasAny() {
return inline, true, nil
}
return connectionSecretBundle{}, false, nil
}
if strings.TrimSpace(view.SecretRef) == "" {
if inline.hasAny() {
return inline, true, nil
}
return connectionSecretBundle{}, false, nil
}
bundle, err := r.loadSecretBundleFromStore(view)
if err == nil {
return bundle, true, nil
return mergeConnectionSecretBundles(bundle, inline), true, nil
}
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
if inline.hasAny() {
return inline, true, nil
}
return connectionSecretBundle{}, false, nil
}
return connectionSecretBundle{}, false, err

View File

@@ -12,6 +12,7 @@ var runtimeGOOS = func() string {
}
func extractConnectionSecretBundle(config connection.ConnectionConfig) connectionSecretBundle {
_, sensitiveParams := partitionConnectionParams(config.ConnectionParams)
return connectionSecretBundle{
Password: config.Password,
SSHPassword: config.SSH.Password,
@@ -22,6 +23,11 @@ func extractConnectionSecretBundle(config connection.ConnectionConfig) connectio
RedisSentinelPassword: config.RedisSentinelPassword,
OpaqueURI: config.URI,
OpaqueDSN: config.DSN,
JVMJMXPassword: config.JVM.JMX.Password,
JVMEndpointAPIKey: config.JVM.Endpoint.APIKey,
JVMAgentAPIKey: config.JVM.Agent.APIKey,
JVMDiagnosticAPIKey: config.JVM.Diagnostic.APIKey,
SensitiveParams: sensitiveParams,
}
}
@@ -36,6 +42,11 @@ func toDailyConnectionBundle(bundle connectionSecretBundle) dailysecret.Connecti
RedisSentinelPassword: bundle.RedisSentinelPassword,
OpaqueURI: bundle.OpaqueURI,
OpaqueDSN: bundle.OpaqueDSN,
JVMJMXPassword: bundle.JVMJMXPassword,
JVMEndpointAPIKey: bundle.JVMEndpointAPIKey,
JVMAgentAPIKey: bundle.JVMAgentAPIKey,
JVMDiagnosticAPIKey: bundle.JVMDiagnosticAPIKey,
SensitiveParams: bundle.SensitiveParams,
}
}
@@ -50,6 +61,11 @@ func fromDailyConnectionBundle(bundle dailysecret.ConnectionBundle) connectionSe
RedisSentinelPassword: bundle.RedisSentinelPassword,
OpaqueURI: bundle.OpaqueURI,
OpaqueDSN: bundle.OpaqueDSN,
JVMJMXPassword: bundle.JVMJMXPassword,
JVMEndpointAPIKey: bundle.JVMEndpointAPIKey,
JVMAgentAPIKey: bundle.JVMAgentAPIKey,
JVMDiagnosticAPIKey: bundle.JVMDiagnosticAPIKey,
SensitiveParams: bundle.SensitiveParams,
}
}
@@ -64,6 +80,11 @@ func stripConnectionSecretFields(config connection.ConnectionConfig) connection.
stripped.RedisSentinelPassword = ""
stripped.URI = ""
stripped.DSN = ""
stripped.JVM.JMX.Password = ""
stripped.JVM.Endpoint.APIKey = ""
stripped.JVM.Agent.APIKey = ""
stripped.JVM.Diagnostic.APIKey = ""
stripped.ConnectionParams, _ = partitionConnectionParams(stripped.ConnectionParams)
return stripped
}

View File

@@ -23,12 +23,9 @@ func (a *App) GetEditableSavedConnection(id string) (connection.SavedConnectionV
if err != nil {
return connection.SavedConnectionView{}, err
}
resolvedConfig, err := a.resolveConnectionSecrets(view.Config)
if err != nil {
return connection.SavedConnectionView{}, err
}
view.Config = resolvedConfig
return view, nil
// Editing relies on the Has* flags and explicit clear fields. Returning the
// resolved bundle would expose every saved credential to the WebView.
return sanitizeSavedConnectionView(view), nil
}
func (a *App) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) {
@@ -74,6 +71,12 @@ func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection)
input.ClearRedisSentinelPassword = strings.TrimSpace(item.Config.RedisSentinelPassword) == ""
input.ClearOpaqueURI = strings.TrimSpace(item.Config.URI) == ""
input.ClearOpaqueDSN = strings.TrimSpace(item.Config.DSN) == ""
input.ClearJVMJMXPassword = strings.TrimSpace(item.Config.JVM.JMX.Password) == ""
input.ClearJVMEndpointAPIKey = strings.TrimSpace(item.Config.JVM.Endpoint.APIKey) == ""
input.ClearJVMAgentAPIKey = strings.TrimSpace(item.Config.JVM.Agent.APIKey) == ""
input.ClearJVMDiagnosticAPIKey = strings.TrimSpace(item.Config.JVM.Diagnostic.APIKey) == ""
_, sensitiveParams := partitionConnectionParams(item.Config.ConnectionParams)
input.ClearSensitiveParams = strings.TrimSpace(sensitiveParams) == ""
inputs = append(inputs, input)
}
views, err := a.importSavedConnectionsAtomically(inputs)

View File

@@ -225,7 +225,7 @@ func TestSaveConnectionSanitizesSchemaVisibilityRules(t *testing.T) {
}
}
func TestGetEditableSavedConnectionReturnsResolvedSecretsForEdit(t *testing.T) {
func TestGetEditableSavedConnectionReturnsSecretlessViewForEdit(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -255,11 +255,11 @@ func TestGetEditableSavedConnectionReturnsResolvedSecretsForEdit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if view.Config.Password != "mysql-secret" {
t.Fatalf("expected editable primary password, got %q", view.Config.Password)
if view.Config.Password != "" {
t.Fatalf("editable view must not expose primary password, got %q", view.Config.Password)
}
if view.Config.SSH.Password != "ssh-secret" {
t.Fatalf("expected editable SSH password, got %q", view.Config.SSH.Password)
if view.Config.SSH.Password != "" {
t.Fatalf("editable view must not expose SSH password, got %q", view.Config.SSH.Password)
}
if !view.HasPrimaryPassword || !view.HasSSHPassword {
t.Fatalf("expected secret flags to stay true, got %#v", view)

View File

@@ -0,0 +1,363 @@
package app
import (
"encoding/json"
"net/url"
"os"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestPartitionConnectionParamsSeparatesCredentialKeys(t *testing.T) {
public, sensitive := partitionConnectionParams(
"application_name=gonavi&connectTimeout=10&accessToken=token-secret&client_secret=client-secret&PASSWORD=db-secret",
)
publicValues, err := url.ParseQuery(public)
if err != nil {
t.Fatal(err)
}
if publicValues.Get("application_name") != "gonavi" || publicValues.Get("connectTimeout") != "10" {
t.Fatalf("ordinary parameters were not preserved: %q", public)
}
if strings.Contains(public, "token-secret") || strings.Contains(public, "client-secret") || strings.Contains(public, "db-secret") {
t.Fatalf("public parameters contain credentials: %q", public)
}
sensitiveValues, err := url.ParseQuery(sensitive)
if err != nil {
t.Fatal(err)
}
if sensitiveValues.Get("accessToken") != "token-secret" ||
sensitiveValues.Get("client_secret") != "client-secret" ||
sensitiveValues.Get("PASSWORD") != "db-secret" {
t.Fatalf("credential parameters were not retained in the secret partition: %q", sensitive)
}
merged, err := url.ParseQuery(mergeConnectionParams(public, sensitive))
if err != nil {
t.Fatal(err)
}
if merged.Get("application_name") != "gonavi" || merged.Get("accessToken") != "token-secret" {
t.Fatalf("merged parameters do not reconstruct runtime configuration: %#v", merged)
}
malformedPublic, malformedSecret := partitionConnectionParams("token=secret;broken")
if malformedPublic != "" || malformedSecret != "token=secret;broken" {
t.Fatalf("malformed parameters must fail closed, public=%q secret=%q", malformedPublic, malformedSecret)
}
}
func TestSavedConnectionJVMSecretsAndSensitiveParamsStayOutOfPublicMetadata(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
input := connection.SavedConnectionInput{
ID: "conn-secret-boundary",
Name: "Secret boundary",
Config: connection.ConnectionConfig{
ID: "conn-secret-boundary",
Type: "jvm",
Host: "jvm.local",
Password: "primary-secret",
URI: "https://user:uri-secret@jvm.local",
DSN: "password=dsn-secret",
ConnectionParams: "application_name=gonavi&accessToken=param-token&client_secret=param-secret",
JVM: connection.JVMConfig{
JMX: connection.JVMJMXConfig{
Enabled: true,
Username: "monitor",
Password: "jmx-secret",
},
Endpoint: connection.JVMEndpointConfig{Enabled: true, BaseURL: "https://endpoint.local", APIKey: "endpoint-key"},
Agent: connection.JVMAgentConfig{Enabled: true, BaseURL: "https://agent.local", APIKey: "agent-key"},
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
BaseURL: "https://diagnostic.local",
APIKey: "diagnostic-key",
},
},
},
}
saved, err := app.SaveConnection(input)
if err != nil {
t.Fatal(err)
}
assertPublicSavedConnectionSecretless(t, saved)
if !saved.HasPrimaryPassword || !saved.HasOpaqueURI || !saved.HasOpaqueDSN ||
!saved.HasJVMJMXPassword || !saved.HasJVMEndpointAPIKey || !saved.HasJVMAgentAPIKey ||
!saved.HasJVMDiagnosticAPIKey || !saved.HasSensitiveParams {
t.Fatalf("expected all saved secret flags, got %#v", saved)
}
rawView, err := app.savedConnectionRepository().Find(input.ID)
if err != nil {
t.Fatal(err)
}
assertPublicSavedConnectionSecretless(t, rawView)
metadata, err := os.ReadFile(app.savedConnectionRepository().connectionsPath())
if err != nil {
t.Fatal(err)
}
assertJSONOmitsSecretLiterals(t, metadata)
bundle, ok, err := app.dailySecretStore().GetConnection(input.ID)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected secret bundle to be persisted")
}
if bundle.JVMJMXPassword != "jmx-secret" || bundle.JVMEndpointAPIKey != "endpoint-key" ||
bundle.JVMAgentAPIKey != "agent-key" || bundle.JVMDiagnosticAPIKey != "diagnostic-key" {
t.Fatalf("JVM credentials were not persisted in the secret store: %#v", bundle)
}
sensitiveValues, err := url.ParseQuery(bundle.SensitiveParams)
if err != nil {
t.Fatal(err)
}
if sensitiveValues.Get("accessToken") != "param-token" || sensitiveValues.Get("client_secret") != "param-secret" {
t.Fatalf("sensitive connection params were not persisted in the secret store: %q", bundle.SensitiveParams)
}
resolved, err := app.resolveConnectionSecrets(rawView.Config)
if err != nil {
t.Fatal(err)
}
if resolved.Password != "primary-secret" || resolved.URI == "" || resolved.DSN == "" ||
resolved.JVM.JMX.Password != "jmx-secret" || resolved.JVM.Endpoint.APIKey != "endpoint-key" ||
resolved.JVM.Agent.APIKey != "agent-key" || resolved.JVM.Diagnostic.APIKey != "diagnostic-key" {
t.Fatalf("runtime secret resolution did not restore credentials: %#v", resolved)
}
resolvedParams, err := url.ParseQuery(resolved.ConnectionParams)
if err != nil {
t.Fatal(err)
}
if resolvedParams.Get("application_name") != "gonavi" || resolvedParams.Get("accessToken") != "param-token" {
t.Fatalf("runtime connection params were not reconstructed: %#v", resolvedParams)
}
listed, err := app.GetSavedConnections()
if err != nil {
t.Fatal(err)
}
if len(listed) != 1 {
t.Fatalf("expected one saved connection, got %d", len(listed))
}
assertPublicSavedConnectionSecretless(t, listed[0])
listedJSON, err := json.Marshal(listed)
if err != nil {
t.Fatal(err)
}
assertJSONOmitsSecretLiterals(t, listedJSON)
editable, err := app.GetEditableSavedConnection(input.ID)
if err != nil {
t.Fatal(err)
}
assertPublicSavedConnectionSecretless(t, editable)
editableJSON, err := json.Marshal(editable)
if err != nil {
t.Fatal(err)
}
assertJSONOmitsSecretLiterals(t, editableJSON)
// A metadata-only edit must keep the existing bundle even though no secret is
// returned to the WebView and therefore none is echoed back.
update := connection.SavedConnectionInput{ID: saved.ID, Name: "Renamed", Config: saved.Config}
updated, err := app.SaveConnection(update)
if err != nil {
t.Fatal(err)
}
if !updated.HasJVMJMXPassword || !updated.HasSensitiveParams {
t.Fatalf("metadata-only save dropped secret flags: %#v", updated)
}
resolvedAfterUpdate, err := app.resolveConnectionSecrets(updated.Config)
if err != nil {
t.Fatal(err)
}
if resolvedAfterUpdate.JVM.JMX.Password != "jmx-secret" ||
!strings.Contains(resolvedAfterUpdate.ConnectionParams, "param-token") {
t.Fatalf("metadata-only save dropped stored secrets: %#v", resolvedAfterUpdate)
}
}
func TestSaveConnectionExplicitlyClearsNewSecretFields(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
initial, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-clear-new-secrets",
Config: connection.ConnectionConfig{
ID: "conn-clear-new-secrets",
Type: "jvm",
ConnectionParams: "application_name=gonavi&token=secret",
JVM: connection.JVMConfig{
JMX: connection.JVMJMXConfig{Password: "jmx-secret"},
Endpoint: connection.JVMEndpointConfig{APIKey: "endpoint-key"},
Agent: connection.JVMAgentConfig{APIKey: "agent-key"},
Diagnostic: connection.JVMDiagnosticConfig{APIKey: "diagnostic-key"},
},
},
})
if err != nil {
t.Fatal(err)
}
cleared, err := app.SaveConnection(connection.SavedConnectionInput{
ID: initial.ID,
Config: initial.Config,
ClearJVMJMXPassword: true,
ClearJVMEndpointAPIKey: true,
ClearJVMAgentAPIKey: true,
ClearJVMDiagnosticAPIKey: true,
ClearSensitiveParams: true,
})
if err != nil {
t.Fatal(err)
}
if cleared.HasJVMJMXPassword || cleared.HasJVMEndpointAPIKey || cleared.HasJVMAgentAPIKey ||
cleared.HasJVMDiagnosticAPIKey || cleared.HasSensitiveParams {
t.Fatalf("explicit clear did not reset secret flags: %#v", cleared)
}
resolved, err := app.resolveConnectionSecrets(cleared.Config)
if err != nil {
t.Fatal(err)
}
if resolved.JVM.JMX.Password != "" || resolved.JVM.Endpoint.APIKey != "" ||
resolved.JVM.Agent.APIKey != "" || resolved.JVM.Diagnostic.APIKey != "" ||
strings.Contains(resolved.ConnectionParams, "secret") {
t.Fatalf("explicit clear did not remove secret material: %#v", resolved)
}
}
func TestConnectionPackageEncryptsAndRestoresNewSecretFields(t *testing.T) {
source := NewAppWithSecretStore(newFakeAppSecretStore())
source.configDir = t.TempDir()
if _, err := source.SaveConnection(connection.SavedConnectionInput{
ID: "conn-package-secrets",
Name: "Package secrets",
Config: connection.ConnectionConfig{
ID: "conn-package-secrets",
Type: "jvm",
ConnectionParams: "application_name=gonavi&token=package-token",
JVM: connection.JVMConfig{
JMX: connection.JVMJMXConfig{Password: "package-jmx-secret"},
Endpoint: connection.JVMEndpointConfig{APIKey: "package-endpoint-key"},
Agent: connection.JVMAgentConfig{APIKey: "package-agent-key"},
Diagnostic: connection.JVMDiagnosticConfig{APIKey: "package-diagnostic-key"},
},
},
}); err != nil {
t.Fatal(err)
}
exported, err := source.buildExportedConnectionPackage(ConnectionExportOptions{IncludeSecrets: true})
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{
"package-token", "package-jmx-secret", "package-endpoint-key", "package-agent-key", "package-diagnostic-key",
} {
if strings.Contains(string(exported), secret) {
t.Fatalf("encrypted connection package contains plaintext %q", secret)
}
}
destination := NewAppWithSecretStore(newFakeAppSecretStore())
destination.configDir = t.TempDir()
result, err := destination.ImportConnectionsPayload(string(exported), "")
if err != nil {
t.Fatal(err)
}
if len(result.Connections) != 1 {
t.Fatalf("expected one imported connection, got %d", len(result.Connections))
}
assertPublicSavedConnectionSecretless(t, result.Connections[0])
resolved, err := destination.resolveConnectionSecrets(result.Connections[0].Config)
if err != nil {
t.Fatal(err)
}
if resolved.JVM.JMX.Password != "package-jmx-secret" ||
resolved.JVM.Endpoint.APIKey != "package-endpoint-key" ||
resolved.JVM.Agent.APIKey != "package-agent-key" ||
resolved.JVM.Diagnostic.APIKey != "package-diagnostic-key" ||
!strings.Contains(resolved.ConnectionParams, "package-token") {
t.Fatalf("import did not restore encrypted secret fields: %#v", resolved)
}
}
func TestDailySecretMigrationMergesInlineJVMSecretsWithExistingBundle(t *testing.T) {
repo := newSavedConnectionRepository(t.TempDir(), newFakeAppSecretStore())
if err := repo.saveAll([]connection.SavedConnectionView{{
ID: "conn-migrate-new-secrets",
Name: "Migration",
HasPrimaryPassword: true,
Config: connection.ConnectionConfig{
ID: "conn-migrate-new-secrets",
Type: "jvm",
ConnectionParams: "application_name=gonavi&accessToken=migrated-token",
JVM: connection.JVMConfig{
JMX: connection.JVMJMXConfig{Password: "migrated-jmx-secret"},
Endpoint: connection.JVMEndpointConfig{APIKey: "migrated-endpoint-key"},
},
},
}}); err != nil {
t.Fatal(err)
}
if err := repo.dailySecrets().PutConnection("conn-migrate-new-secrets", toDailyConnectionBundle(connectionSecretBundle{
Password: "existing-primary-secret",
})); err != nil {
t.Fatal(err)
}
if err := migrateSavedConnectionSecrets(repo, legacyWebKitVisibleConfig{}); err != nil {
t.Fatal(err)
}
view, err := repo.Find("conn-migrate-new-secrets")
if err != nil {
t.Fatal(err)
}
assertPublicSavedConnectionSecretless(t, view)
if !view.HasPrimaryPassword || !view.HasJVMJMXPassword || !view.HasJVMEndpointAPIKey || !view.HasSensitiveParams {
t.Fatalf("migration did not preserve all secret flags: %#v", view)
}
bundle, ok, err := repo.dailySecrets().GetConnection(view.ID)
if err != nil || !ok {
t.Fatalf("expected migrated bundle, ok=%v err=%v", ok, err)
}
if bundle.Password != "existing-primary-secret" || bundle.JVMJMXPassword != "migrated-jmx-secret" ||
bundle.JVMEndpointAPIKey != "migrated-endpoint-key" || !strings.Contains(bundle.SensitiveParams, "migrated-token") {
t.Fatalf("migration dropped old or new secret material: %#v", bundle)
}
}
func assertPublicSavedConnectionSecretless(t *testing.T, view connection.SavedConnectionView) {
t.Helper()
config := view.Config
if config.Password != "" || config.SSH.Password != "" || config.Proxy.Password != "" ||
config.HTTPTunnel.Password != "" || config.MySQLReplicaPassword != "" ||
config.MongoReplicaPassword != "" || config.RedisSentinelPassword != "" ||
config.URI != "" || config.DSN != "" || config.JVM.JMX.Password != "" ||
config.JVM.Endpoint.APIKey != "" || config.JVM.Agent.APIKey != "" ||
config.JVM.Diagnostic.APIKey != "" {
t.Fatalf("public saved connection contains a secret field: %#v", config)
}
_, sensitive := partitionConnectionParams(config.ConnectionParams)
if sensitive != "" {
t.Fatalf("public saved connection contains sensitive connection params: %q", config.ConnectionParams)
}
}
func assertJSONOmitsSecretLiterals(t *testing.T, payload []byte) {
t.Helper()
for _, secret := range []string{
"primary-secret", "uri-secret", "dsn-secret", "param-token", "param-secret",
"jmx-secret", "endpoint-key", "agent-key", "diagnostic-key",
} {
if strings.Contains(string(payload), secret) {
t.Fatalf("public/metadata JSON contains secret literal %q: %s", secret, payload)
}
}
}

View File

@@ -60,6 +60,11 @@ type connectionSecretBundle struct {
RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"`
OpaqueURI string `json:"opaqueURI,omitempty"`
OpaqueDSN string `json:"opaqueDSN,omitempty"`
JVMJMXPassword string `json:"jvmJMXPassword,omitempty"`
JVMEndpointAPIKey string `json:"jvmEndpointAPIKey,omitempty"`
JVMAgentAPIKey string `json:"jvmAgentAPIKey,omitempty"`
JVMDiagnosticAPIKey string `json:"jvmDiagnosticAPIKey,omitempty"`
SensitiveParams string `json:"sensitiveConnectionParams,omitempty"`
}
type savedConnectionsFile struct {
@@ -94,7 +99,12 @@ func (b connectionSecretBundle) hasAny() bool {
strings.TrimSpace(b.MongoReplicaPassword) != "" ||
strings.TrimSpace(b.RedisSentinelPassword) != "" ||
strings.TrimSpace(b.OpaqueURI) != "" ||
strings.TrimSpace(b.OpaqueDSN) != ""
strings.TrimSpace(b.OpaqueDSN) != "" ||
strings.TrimSpace(b.JVMJMXPassword) != "" ||
strings.TrimSpace(b.JVMEndpointAPIKey) != "" ||
strings.TrimSpace(b.JVMAgentAPIKey) != "" ||
strings.TrimSpace(b.JVMDiagnosticAPIKey) != "" ||
strings.TrimSpace(b.SensitiveParams) != ""
}
func mergeConnectionSecretBundles(base, overlay connectionSecretBundle) connectionSecretBundle {
@@ -126,6 +136,21 @@ func mergeConnectionSecretBundles(base, overlay connectionSecretBundle) connecti
if strings.TrimSpace(overlay.OpaqueDSN) != "" {
merged.OpaqueDSN = overlay.OpaqueDSN
}
if strings.TrimSpace(overlay.JVMJMXPassword) != "" {
merged.JVMJMXPassword = overlay.JVMJMXPassword
}
if strings.TrimSpace(overlay.JVMEndpointAPIKey) != "" {
merged.JVMEndpointAPIKey = overlay.JVMEndpointAPIKey
}
if strings.TrimSpace(overlay.JVMAgentAPIKey) != "" {
merged.JVMAgentAPIKey = overlay.JVMAgentAPIKey
}
if strings.TrimSpace(overlay.JVMDiagnosticAPIKey) != "" {
merged.JVMDiagnosticAPIKey = overlay.JVMDiagnosticAPIKey
}
if strings.TrimSpace(overlay.SensitiveParams) != "" {
merged.SensitiveParams = overlay.SensitiveParams
}
return merged
}
@@ -158,6 +183,21 @@ func applyConnectionSecretClears(bundle connectionSecretBundle, input connection
if input.ClearOpaqueDSN {
cleared.OpaqueDSN = ""
}
if input.ClearJVMJMXPassword {
cleared.JVMJMXPassword = ""
}
if input.ClearJVMEndpointAPIKey {
cleared.JVMEndpointAPIKey = ""
}
if input.ClearJVMAgentAPIKey {
cleared.JVMAgentAPIKey = ""
}
if input.ClearJVMDiagnosticAPIKey {
cleared.JVMDiagnosticAPIKey = ""
}
if input.ClearSensitiveParams {
cleared.SensitiveParams = ""
}
return cleared
}
@@ -332,6 +372,11 @@ func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.S
HasRedisSentinelPassword: strings.TrimSpace(bundle.RedisSentinelPassword) != "",
HasOpaqueURI: strings.TrimSpace(bundle.OpaqueURI) != "",
HasOpaqueDSN: strings.TrimSpace(bundle.OpaqueDSN) != "",
HasJVMJMXPassword: strings.TrimSpace(bundle.JVMJMXPassword) != "",
HasJVMEndpointAPIKey: strings.TrimSpace(bundle.JVMEndpointAPIKey) != "",
HasJVMAgentAPIKey: strings.TrimSpace(bundle.JVMAgentAPIKey) != "",
HasJVMDiagnosticAPIKey: strings.TrimSpace(bundle.JVMDiagnosticAPIKey) != "",
HasSensitiveParams: strings.TrimSpace(bundle.SensitiveParams) != "",
}
return view, bundle
}
@@ -589,7 +634,8 @@ func (r *savedConnectionRepository) loadSecretBundleFromStore(view connection.Sa
func savedConnectionViewHasSecrets(view connection.SavedConnectionView) bool {
return view.HasPrimaryPassword || view.HasSSHPassword || view.HasProxyPassword || view.HasHTTPTunnelPassword ||
view.HasMySQLReplicaPassword || view.HasMongoReplicaPassword || view.HasRedisSentinelPassword || view.HasOpaqueURI || view.HasOpaqueDSN
view.HasMySQLReplicaPassword || view.HasMongoReplicaPassword || view.HasRedisSentinelPassword || view.HasOpaqueURI || view.HasOpaqueDSN ||
view.HasJVMJMXPassword || view.HasJVMEndpointAPIKey || view.HasJVMAgentAPIKey || view.HasJVMDiagnosticAPIKey || view.HasSensitiveParams
}
func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle connectionSecretBundle) {
@@ -602,6 +648,11 @@ func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle con
view.HasRedisSentinelPassword = strings.TrimSpace(bundle.RedisSentinelPassword) != ""
view.HasOpaqueURI = strings.TrimSpace(bundle.OpaqueURI) != ""
view.HasOpaqueDSN = strings.TrimSpace(bundle.OpaqueDSN) != ""
view.HasJVMJMXPassword = strings.TrimSpace(bundle.JVMJMXPassword) != ""
view.HasJVMEndpointAPIKey = strings.TrimSpace(bundle.JVMEndpointAPIKey) != ""
view.HasJVMAgentAPIKey = strings.TrimSpace(bundle.JVMAgentAPIKey) != ""
view.HasJVMDiagnosticAPIKey = strings.TrimSpace(bundle.JVMDiagnosticAPIKey) != ""
view.HasSensitiveParams = strings.TrimSpace(bundle.SensitiveParams) != ""
}
func buildDuplicateConnectionName(baseName string, existing []connection.SavedConnectionView, unnamedName string, copySuffix string) string {

View File

@@ -29,6 +29,11 @@ type SavedConnectionInput struct {
ClearRedisSentinelPassword bool `json:"clearRedisSentinelPassword,omitempty"`
ClearOpaqueURI bool `json:"clearOpaqueURI,omitempty"`
ClearOpaqueDSN bool `json:"clearOpaqueDSN,omitempty"`
ClearJVMJMXPassword bool `json:"clearJVMJMXPassword,omitempty"`
ClearJVMEndpointAPIKey bool `json:"clearJVMEndpointAPIKey,omitempty"`
ClearJVMAgentAPIKey bool `json:"clearJVMAgentAPIKey,omitempty"`
ClearJVMDiagnosticAPIKey bool `json:"clearJVMDiagnosticAPIKey,omitempty"`
ClearSensitiveParams bool `json:"clearSensitiveConnectionParams,omitempty"`
}
type SavedConnectionView struct {
@@ -53,6 +58,11 @@ type SavedConnectionView struct {
HasRedisSentinelPassword bool `json:"hasRedisSentinelPassword,omitempty"`
HasOpaqueURI bool `json:"hasOpaqueURI,omitempty"`
HasOpaqueDSN bool `json:"hasOpaqueDSN,omitempty"`
HasJVMJMXPassword bool `json:"hasJVMJMXPassword,omitempty"`
HasJVMEndpointAPIKey bool `json:"hasJVMEndpointAPIKey,omitempty"`
HasJVMAgentAPIKey bool `json:"hasJVMAgentAPIKey,omitempty"`
HasJVMDiagnosticAPIKey bool `json:"hasJVMDiagnosticAPIKey,omitempty"`
HasSensitiveParams bool `json:"hasSensitiveConnectionParams,omitempty"`
}
type LegacySavedConnection = SavedConnectionInput

View File

@@ -22,6 +22,11 @@ type ConnectionBundle struct {
RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"`
OpaqueURI string `json:"opaqueURI,omitempty"`
OpaqueDSN string `json:"opaqueDSN,omitempty"`
JVMJMXPassword string `json:"jvmJMXPassword,omitempty"`
JVMEndpointAPIKey string `json:"jvmEndpointAPIKey,omitempty"`
JVMAgentAPIKey string `json:"jvmAgentAPIKey,omitempty"`
JVMDiagnosticAPIKey string `json:"jvmDiagnosticAPIKey,omitempty"`
SensitiveParams string `json:"sensitiveConnectionParams,omitempty"`
}
func (b ConnectionBundle) HasAny() bool {
@@ -33,7 +38,12 @@ func (b ConnectionBundle) HasAny() bool {
strings.TrimSpace(b.MongoReplicaPassword) != "" ||
strings.TrimSpace(b.RedisSentinelPassword) != "" ||
strings.TrimSpace(b.OpaqueURI) != "" ||
strings.TrimSpace(b.OpaqueDSN) != ""
strings.TrimSpace(b.OpaqueDSN) != "" ||
strings.TrimSpace(b.JVMJMXPassword) != "" ||
strings.TrimSpace(b.JVMEndpointAPIKey) != "" ||
strings.TrimSpace(b.JVMAgentAPIKey) != "" ||
strings.TrimSpace(b.JVMDiagnosticAPIKey) != "" ||
strings.TrimSpace(b.SensitiveParams) != ""
}
type GlobalProxyBundle struct {

View File

@@ -1,15 +1,23 @@
package dailysecret
import "testing"
import (
"reflect"
"testing"
)
func TestStorePutGetDeleteConnectionSecret(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
bundle := ConnectionBundle{
Password: "postgres-secret",
OpaqueDSN: "postgres://user:pass@db.local/app",
SSHPassword: "ssh-secret",
Password: "postgres-secret",
OpaqueDSN: "postgres://user:pass@db.local/app",
SSHPassword: "ssh-secret",
JVMJMXPassword: "jmx-secret",
JVMEndpointAPIKey: "endpoint-key",
JVMAgentAPIKey: "agent-key",
JVMDiagnosticAPIKey: "diagnostic-key",
SensitiveParams: "accessToken=param-secret",
}
if err := store.PutConnection("conn-1", bundle); err != nil {
t.Fatalf("PutConnection returned error: %v", err)
@@ -22,7 +30,7 @@ func TestStorePutGetDeleteConnectionSecret(t *testing.T) {
if !ok {
t.Fatal("expected connection bundle to exist")
}
if got.Password != "postgres-secret" || got.OpaqueDSN != bundle.OpaqueDSN || got.SSHPassword != "ssh-secret" {
if !reflect.DeepEqual(got, bundle) {
t.Fatalf("unexpected bundle: %#v", got)
}