diff --git a/internal/app/connection_package_appkey.go b/internal/app/connection_package_appkey.go index 0ba467d4..eb4a12aa 100644 --- a/internal/app/connection_package_appkey.go +++ b/internal/app/connection_package_appkey.go @@ -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 } diff --git a/internal/app/connection_package_appkey_test.go b/internal/app/connection_package_appkey_test.go index 77f668d5..a54131fa 100644 --- a/internal/app/connection_package_appkey_test.go +++ b/internal/app/connection_package_appkey_test.go @@ -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) } } diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go index aa204acb..e1f5c964 100644 --- a/internal/app/connection_package_transfer.go +++ b/internal/app/connection_package_transfer.go @@ -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) == "", } } diff --git a/internal/app/connection_secret_params.go b/internal/app/connection_secret_params.go new file mode 100644 index 00000000..34f6bae7 --- /dev/null +++ b/internal/app/connection_secret_params.go @@ -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() +} diff --git a/internal/app/connection_secret_resolution.go b/internal/app/connection_secret_resolution.go index 1f387fce..37e7702c 100644 --- a/internal/app/connection_secret_resolution.go +++ b/internal/app/connection_secret_resolution.go @@ -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 } diff --git a/internal/app/daily_secret_migration.go b/internal/app/daily_secret_migration.go index 431584de..508033db 100644 --- a/internal/app/daily_secret_migration.go +++ b/internal/app/daily_secret_migration.go @@ -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 diff --git a/internal/app/daily_secret_persistence.go b/internal/app/daily_secret_persistence.go index 186649ad..0845ddd5 100644 --- a/internal/app/daily_secret_persistence.go +++ b/internal/app/daily_secret_persistence.go @@ -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 } diff --git a/internal/app/methods_saved_connections.go b/internal/app/methods_saved_connections.go index 0858478c..f72bb3f0 100644 --- a/internal/app/methods_saved_connections.go +++ b/internal/app/methods_saved_connections.go @@ -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) diff --git a/internal/app/methods_saved_connections_test.go b/internal/app/methods_saved_connections_test.go index 01dad4b6..4716191e 100644 --- a/internal/app/methods_saved_connections_test.go +++ b/internal/app/methods_saved_connections_test.go @@ -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) diff --git a/internal/app/saved_connection_secret_boundary_test.go b/internal/app/saved_connection_secret_boundary_test.go new file mode 100644 index 00000000..9c28502e --- /dev/null +++ b/internal/app/saved_connection_secret_boundary_test.go @@ -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) + } + } +} diff --git a/internal/app/saved_connections.go b/internal/app/saved_connections.go index 7d1cd1ef..886d8a07 100644 --- a/internal/app/saved_connections.go +++ b/internal/app/saved_connections.go @@ -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 { diff --git a/internal/connection/saved_types.go b/internal/connection/saved_types.go index 7455b7f5..f4152b95 100644 --- a/internal/connection/saved_types.go +++ b/internal/connection/saved_types.go @@ -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 diff --git a/internal/dailysecret/store.go b/internal/dailysecret/store.go index a0badbc3..3b083ece 100644 --- a/internal/dailysecret/store.go +++ b/internal/dailysecret/store.go @@ -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 { diff --git a/internal/dailysecret/store_test.go b/internal/dailysecret/store_test.go index e6a3cec4..2c1da9cf 100644 --- a/internal/dailysecret/store_test.go +++ b/internal/dailysecret/store_test.go @@ -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) }