This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 08bffc38ed obfuscate marketplace repository passwords in
hop-config.json. fixes #7806 (#7828)
08bffc38ed is described below
commit 08bffc38ed50ebd402427cb781ed23ffae76f33a
Author: Bart Maertens <[email protected]>
AuthorDate: Fri Aug 7 13:57:08 2026 +0200
obfuscate marketplace repository passwords in hop-config.json. fixes #7806
(#7828)
---
.../ROOT/pages/hop-tools/hop-marketplace.adoc | 25 +++-
plugins/misc/marketplace/README.md | 24 ++++
.../marketplace/command/MarketplaceCommand.java | 4 +-
.../hop/marketplace/config/MarketplaceHttp.java | 12 ++
.../marketplace/config/MarketplaceRepository.java | 19 ++-
.../config/MarketplaceRepositoryDefinition.java | 8 +-
.../hop/marketplace/config/MarketplaceSecrets.java | 124 ++++++++++++++++
.../hop/marketplace/env/HopEnvironmentLoader.java | 3 +-
.../hop/marketplace/env/HopEnvironmentSpec.java | 10 ++
.../hop/marketplace/gui/HopEnvironmentDialog.java | 4 +
.../gui/MarketplaceRepositoriesPanel.java | 11 +-
.../gui/messages/messages_en_US.properties | 2 +
.../config/RepositoryPasswordStorageTest.java | 159 +++++++++++++++++++++
13 files changed, 391 insertions(+), 14 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
index 556c1dda09..93af602592 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
@@ -362,7 +362,7 @@ repositories:
# - id: corporate
# url: "https://nexus.example.com/repository/hop-plugins/"
# username: reader
- # password: "…" # prefer env HOP_MARKETPLACE_PASSWORD for secrets
+ # password: "${CORPORATE_TOKEN}" # stored obfuscated; prefer a variable
or env HOP_MARKETPLACE_PASSWORD
# Required optional plugins (installed into plugins/)
plugins:
@@ -457,6 +457,29 @@ Credentials are resolved most-specific first:
Use the repository-scoped variables when more than one private repository is
configured, since the global pair cannot express two different tokens.
+Username and password are resolved separately, so the two can come from
different sources: a repository entry that sets only `username` is combined
with `HOP_MARKETPLACE_PASSWORD` from the environment. Such a mixed pair counts
as deliberately configured — the anonymous retry described below applies only
when the repository entry supplies neither field.
+
+=== How stored passwords are protected
+
+A `password` on a repository entry is written to `hop-config.json` — and to
exported repository definitions and environment files — obfuscated with Hop's
two-way password encoder, the same `Encrypted 2be98afc…` form used for Hop
server and database passwords. Configurations written by older versions are
read as-is and re-encoded the next time they are saved.
+
+Obfuscation is not encryption: the encoder key is not a secret, so anyone
holding the file and a Hop install can recover the password. It stops
credentials being read over a shoulder, in a screenshot or in a diff — nothing
more.
+
+To keep the secret out of the file altogether, put a variable or a variable
resolver expression in `username` / `password` instead of the credential:
+
+[source,json]
+----
+{ "id": "acme", "url": "https://nexus.example.org/repository/hop/",
"username": "reader", "password": "${ACME_TOKEN}" }
+----
+
+Variables are stored exactly as typed — there is nothing secret to obfuscate —
and are resolved when the repository is contacted, so the secret lives in the
variable rather than in the file.
+
+Marketplace repositories are client-wide rather than project-bound, so
`${...}` resolves against client-level variables only: JVM system properties
(`-DACME_TOKEN=…`, for example through `HOP_OPTIONS`) and variables described
in `hop-config.json`. Project and environment variables are not visible here,
and neither are plain shell environment variables — for those, use the
`HOP_MARKETPLACE_*` names above. A xref:variables.adoc[variable resolver]
expression such as `#{vault:hop/marketplace [...]
+
+A variable that is not set is sent to the server unresolved, and the resulting
401 says so.
+
+The `HOP_MARKETPLACE_*` environment variables above remain the simplest option
when nothing at all should be written to the configuration.
+
Because the global variables are offered to every repository — including
public ones such as the ASF repository and Maven Central, which reject unknown
credentials with HTTP 401 instead of ignoring them — a request that fails with
401 or 403 using credentials that came _only_ from the environment is retried
once anonymously. Credentials configured on the repository entry are treated as
deliberate and are never dropped, so those failures are reported as-is.
The same credentials apply to `hop marketplace repo import <https URL>`, so a
shared repository definition can be hosted in a private repository. No
repository entry exists at import time, so only the environment variables are
available there.
diff --git a/plugins/misc/marketplace/README.md
b/plugins/misc/marketplace/README.md
index cc2b709dec..78849ab41c 100644
--- a/plugins/misc/marketplace/README.md
+++ b/plugins/misc/marketplace/README.md
@@ -194,6 +194,30 @@ Credentials resolve most-specific first:
Use the scoped form when more than one private repository is configured; the
global pair cannot express two different tokens.
+Username and password resolve separately: an entry with only `username` set is
+combined with `HOP_MARKETPLACE_PASSWORD` from the environment. A mixed pair
+counts as deliberate — the anonymous retry below applies only when the entry
+supplies neither field.
+
+A `password` on a repository entry is stored obfuscated (`Encrypted 2be98afc…`,
+Hop's two-way password encoder) in `hop-config.json`, in exported repository
+definitions and in environment files. Older clear-text configurations are read
+as-is and re-encoded on the next save. Obfuscation is not encryption — the key
+is public — so to keep the secret out of the file use a variable instead:
+
+```json
+{ "id": "acme", "url": "https://nexus.example.org/repository/hop/",
"password": "${ACME_TOKEN}" }
+```
+
+Variables and variable resolver expressions are stored as typed and resolved
+when the repository is contacted. Repositories are client-wide, so `${...}`
+resolves against client-level variables only: JVM system properties (`-D…`,
+e.g. via `HOP_OPTIONS`) and variables described in `hop-config.json` — not
+project/environment variables, and not plain shell exports (use the
+`HOP_MARKETPLACE_*` names for those). `#{resolver:…}` expressions go through
the
+active metadata. One that is not set reaches the server unresolved; the 401
+message says so.
+
The global pair is offered to **every** repository, including public ones, and
ASF/Central answer unknown credentials with 401 rather than ignoring them. So a
request that fails with 401/403 using credentials that came *only* from the
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
index fa5507f51f..2b12aab654 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
@@ -675,7 +675,9 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
@Option(
names = {"--password"},
- description = "Optional Basic auth password (prefer env
HOP_MARKETPLACE_PASSWORD)")
+ description =
+ "Optional Basic auth password, stored obfuscated (prefer a
${VARIABLE} or env"
+ + " HOP_MARKETPLACE_PASSWORD)")
private String password;
@Option(
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceHttp.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceHttp.java
index 7e0d7e7df1..528a6778b8 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceHttp.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceHttp.java
@@ -156,11 +156,23 @@ public final class MarketplaceHttp {
+ repository.environmentIdPrefix()
+ "_USERNAME / _PASSWORD if this repository needs different ones.";
}
+ if (unresolved(repository.effectiveUsername()) ||
unresolved(repository.effectivePassword())) {
+ return ". The credentials configured for this repository use a variable
that is not set, so"
+ + " the expression itself was sent as the credential. Define the
variable, or set"
+ + " HOP_MARKETPLACE_"
+ + repository.environmentIdPrefix()
+ + "_USERNAME / _PASSWORD instead.";
+ }
return ". Basic auth was sent as user '"
+ repository.effectiveUsername()
+ "' from the repository configuration; check that password.";
}
+ /** A credential that still carries variable syntax never made it through
resolution. */
+ private static boolean unresolved(String credential) {
+ return credential != null && (credential.contains("${") ||
credential.contains("#{"));
+ }
+
private static void close(Object body) {
if (body instanceof Closeable closeable) {
try {
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
index 6d73d488be..300899fe42 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
@@ -17,6 +17,8 @@
package org.apache.hop.marketplace.config;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -56,10 +58,14 @@ public class MarketplaceRepository {
private String username;
/**
- * Optional HTTP Basic auth password. Prefer leaving this empty in
hop-config.json and setting
- * {@code HOP_MARKETPLACE_PASSWORD} instead for private repos. Do not set
for anonymous ASF /
- * Central / local Nexus.
+ * Optional HTTP Basic auth password, held in plain form in memory and
obfuscated in
+ * hop-config.json (see {@link MarketplaceSecrets}). Obfuscation is not
encryption, so for private
+ * repos prefer a variable ({@code ${MY_TOKEN}}), a variable resolver
expression, or {@code
+ * HOP_MARKETPLACE_PASSWORD} — those keep the secret out of the file
entirely. Do not set for
+ * anonymous ASF / Central / local Nexus.
*/
+ @JsonSerialize(using = MarketplaceSecrets.Serializer.class)
+ @JsonDeserialize(using = MarketplaceSecrets.Deserializer.class)
private String password;
/**
@@ -206,10 +212,13 @@ public class MarketplaceRepository {
* <p>Scoped variables exist so several private repositories can each have
their own token; the
* global pair applies to every repository, which is why {@link
#credentialsFromEnvironmentOnly()}
* exists to let a rejected global credential fall back to anonymous.
+ *
+ * <p>Credentials configured on the entry may be variables or variable
resolver expressions; they
+ * are resolved here, at use, so the config file only ever holds the
reference.
*/
public String effectiveUsername() {
if (StringUtils.isNotBlank(username)) {
- return username;
+ return MarketplaceSecrets.resolve(username);
}
return firstNonBlank(
scopedEnv("USERNAME"),
@@ -220,7 +229,7 @@ public class MarketplaceRepository {
public String effectivePassword() {
if (StringUtils.isNotBlank(password)) {
- return password;
+ return MarketplaceSecrets.resolve(password);
}
return firstNonBlank(scopedEnv("PASSWORD"),
env("HOP_MARKETPLACE_PASSWORD"));
}
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
index fca58a11c7..950f0a320b 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepositoryDefinition.java
@@ -41,7 +41,8 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* Shareable Hop marketplace repository definition ({@code
hop-marketplace-repo.yaml}). Passwords
- * are never written on export.
+ * are never written on export unless explicitly asked for, and then only
obfuscated (see {@link
+ * MarketplaceSecrets}).
*/
public final class MarketplaceRepositoryDefinition {
@@ -160,7 +161,8 @@ public final class MarketplaceRepositoryDefinition {
// password intentionally ignored on import unless present (discouraged)
String password = stringVal(map.get("password"));
if (StringUtils.isNotBlank(password)) {
- repo.setPassword(password);
+ // Obfuscated when exported by Hop, clear text when hand-written: both
are accepted.
+ repo.setPassword(MarketplaceSecrets.decode(password));
}
repo.setBrowse(boolVal(map.get("browse"), false));
repo.setCatalogUrl(stringVal(map.get("catalogUrl")));
@@ -263,7 +265,7 @@ public final class MarketplaceRepositoryDefinition {
root.put("username", repo.getUsername());
}
if (includePassword && StringUtils.isNotBlank(repo.getPassword())) {
- root.put("password", repo.getPassword());
+ root.put("password", MarketplaceSecrets.encode(repo.getPassword()));
}
root.put("browse", repo.isBrowse());
if (StringUtils.isNotBlank(repo.getCatalogUrl())) {
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceSecrets.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceSecrets.java
new file mode 100644
index 0000000000..b78238b317
--- /dev/null
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceSecrets.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.marketplace.config;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import java.io.IOException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.encryption.ITwoWayPasswordEncoder;
+import org.apache.hop.core.variables.Variables;
+
+/**
+ * Password handling for marketplace repository credentials.
+ *
+ * <p>Repository passwords used to be written to hop-config.json in clear
text. They now pass
+ * through Hop's two-way password encoder on the way out ({@code Encrypted
2be98afc...}) and back
+ * through it on the way in, the same way Hop server and database passwords
are handled.
+ *
+ * <p>This is obfuscation, not encryption: the encoder key is not a secret, so
anyone with the
+ * config file and a Hop install can recover the password. It keeps
credentials from being readable
+ * over a shoulder, in a screenshot or in a diff — nothing more. Point the
password at a variable
+ * ({@code ${MY_TOKEN}}), a variable resolver expression, or one of the {@code
HOP_MARKETPLACE_*}
+ * environment variables when the secret must not live in the file at all.
+ */
+public final class MarketplaceSecrets {
+
+ private MarketplaceSecrets() {}
+
+ /**
+ * Used when no encoder is bound yet — {@link Encr} stays uninitialized
until {@code
+ * HopClientEnvironment.init()} runs, and config can be read before that.
Falling back keeps the
+ * value obfuscated instead of failing or silently writing clear text.
+ */
+ private static final ITwoWayPasswordEncoder FALLBACK = new
HopTwoWayPasswordEncoder();
+
+ /**
+ * Obfuscated form for storage. Values that use variables are stored as
typed: both shipped
+ * encoders leave them alone so the expression survives to be resolved at
use.
+ */
+ public static String encode(String password) {
+ if (StringUtils.isEmpty(password)) {
+ return password;
+ }
+ return encoder().encode(password, true);
+ }
+
+ /**
+ * Plain form of a stored value. Anything without the encoder's prefix is
returned unchanged, so
+ * configs written before this was added keep working.
+ */
+ public static String decode(String password) {
+ if (StringUtils.isEmpty(password)) {
+ return password;
+ }
+ return encoder().decode(password, true);
+ }
+
+ /**
+ * Resolve variables and variable resolver expressions in a credential.
Unresolvable expressions
+ * come back unchanged — that reaches the server as a bad password, which
{@link
+ * MarketplaceHttp#authHint} explains.
+ */
+ public static String resolve(String value) {
+ if (StringUtils.isEmpty(value) || !value.contains("{")) {
+ return value;
+ }
+ try {
+ return Variables.getADefaultVariableSpace().resolve(value);
+ } catch (Exception e) {
+ // Resolvers reach out to metadata and secret managers; a broken one
must not break the
+ // repository. Sending the expression unresolved fails visibly with an
auth hint.
+ return value;
+ }
+ }
+
+ private static ITwoWayPasswordEncoder encoder() {
+ ITwoWayPasswordEncoder active = Encr.getEncoder();
+ return active == null ? FALLBACK : active;
+ }
+
+ /** Writes the obfuscated form of a password field. */
+ public static class Serializer extends JsonSerializer<String> {
+ @Override
+ public void serialize(String value, JsonGenerator generator,
SerializerProvider provider)
+ throws IOException {
+ String encoded = encode(value);
+ if (encoded == null) {
+ generator.writeNull();
+ } else {
+ generator.writeString(encoded);
+ }
+ }
+ }
+
+ /** Reads a password field, obfuscated or (legacy) clear text. */
+ public static class Deserializer extends JsonDeserializer<String> {
+ @Override
+ public String deserialize(JsonParser parser, DeserializationContext
context)
+ throws IOException {
+ return decode(parser.getValueAsString());
+ }
+ }
+}
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentLoader.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentLoader.java
index ee6cfa22a6..b4b83dcb64 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentLoader.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentLoader.java
@@ -31,6 +31,7 @@ import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.json.HopJson;
+import org.apache.hop.marketplace.config.MarketplaceSecrets;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
@@ -132,7 +133,7 @@ public final class HopEnvironmentLoader {
m.put("username", ref.getUsername());
}
if (StringUtils.isNotBlank(ref.getPassword())) {
- m.put("password", ref.getPassword());
+ m.put("password", MarketplaceSecrets.encode(ref.getPassword()));
}
repos.add(m);
}
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentSpec.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentSpec.java
index 1efee5f64c..8e17e18c86 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentSpec.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopEnvironmentSpec.java
@@ -17,10 +17,13 @@
package org.apache.hop.marketplace.env;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
+import org.apache.hop.marketplace.config.MarketplaceSecrets;
/**
* Declarative Hop environment file ({@code hop-env.yaml} / {@code
hop-env.json}).
@@ -60,6 +63,13 @@ public class HopEnvironmentSpec {
/** Optional Basic auth username (prefer env HOP_MARKETPLACE_PASSWORD for
secrets). */
private String username;
+ /**
+ * Optional Basic auth password, obfuscated in the environment file the
same way as in
+ * hop-config.json. Environment files are meant to be shared, so prefer a
variable or {@code
+ * HOP_MARKETPLACE_PASSWORD} over a password here.
+ */
+ @JsonSerialize(using = MarketplaceSecrets.Serializer.class)
+ @JsonDeserialize(using = MarketplaceSecrets.Deserializer.class)
private String password;
}
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/HopEnvironmentDialog.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/HopEnvironmentDialog.java
index dec6c41b1a..c5bec81e73 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/HopEnvironmentDialog.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/HopEnvironmentDialog.java
@@ -1212,6 +1212,10 @@ public class HopEnvironmentDialog extends Dialog {
Text wPass = new Text(dialog, SWT.SINGLE | SWT.LEFT | SWT.BORDER |
SWT.PASSWORD);
PropsUi.setLook(wPass);
wPass.setText(Const.NVL(ref.getPassword(), ""));
+ String passwordTooltip =
+ BaseMessages.getString(PKG,
"HopEnvironmentDialog.Repo.Column.Password.Tooltip");
+ wlPass.setToolTipText(passwordTooltip);
+ wPass.setToolTipText(passwordTooltip);
FormData fdPass = new FormData();
fdPass.left = new FormAttachment(middle, 0);
fdPass.top = new FormAttachment(wlPass, 0, SWT.CENTER);
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceRepositoriesPanel.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceRepositoriesPanel.java
index 61e00d4483..4fa7f4ceb2 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceRepositoriesPanel.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceRepositoriesPanel.java
@@ -447,6 +447,11 @@ public class MarketplaceRepositoriesPanel {
wlPass.setLayoutData(fdlPass);
Text wPass = new Text(general, SWT.SINGLE | SWT.LEFT | SWT.BORDER |
SWT.PASSWORD);
PropsUi.setLook(wPass);
+ wPass.setText(Const.NVL(repo.getPassword(), ""));
+ String passwordTooltip =
+ BaseMessages.getString(PKG,
"ManageRepositoriesDialog.Edit.Password.Tooltip");
+ wlPass.setToolTipText(passwordTooltip);
+ wPass.setToolTipText(passwordTooltip);
FormData fdPass = new FormData();
fdPass.left = new FormAttachment(middle, margin);
fdPass.top = new FormAttachment(wlPass, 0, SWT.CENTER);
@@ -725,9 +730,9 @@ public class MarketplaceRepositoriesPanel {
repo.setName(wName.getText().trim());
repo.setUrl(wUrl.getText().trim());
repo.setUsername(StringUtils.trimToNull(wUser.getText()));
- if (StringUtils.isNotBlank(wPass.getText())) {
- repo.setPassword(wPass.getText());
- }
+ // Empty means no password: clearing the field removes a stored
credential, which is the
+ // only way back to environment-only credentials for this repository.
+ repo.setPassword(StringUtils.trimToNull(wPass.getText()));
repo.setEnabled(wEnabled.getSelection());
repo.setPrimary(wPrimary.getSelection());
repo.setBrowse(wBrowse.getSelection());
diff --git
a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
index 960f21ea8d..c5c2dca2ff 100644
---
a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
+++
b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
@@ -170,6 +170,7 @@ ManageRepositoriesDialog.Edit.Name=Name
ManageRepositoriesDialog.Edit.Url=URL
ManageRepositoriesDialog.Edit.Username=Username
ManageRepositoriesDialog.Edit.Password=Password
+ManageRepositoriesDialog.Edit.Password.Tooltip=Empty means no password: clear
the field to fall back to the HOP_MARKETPLACE_<ID>_PASSWORD environment
variable. Stored obfuscated in hop-config.json, which is not encryption: to
keep the secret out of the file use a variable like $'{'MY_TOKEN'}'.
ManageRepositoriesDialog.Edit.Enabled=Enabled (use in fallback chain)
ManageRepositoriesDialog.Edit.Primary=Primary (tried first)
ManageRepositoriesDialog.Edit.Browse=Browse (list zips in query / Plugins tab)
@@ -248,6 +249,7 @@ HopEnvironmentDialog.Repo.Column.Id=Id
HopEnvironmentDialog.Repo.Column.Url=URL
HopEnvironmentDialog.Repo.Column.Username=Username
HopEnvironmentDialog.Repo.Column.Password=Password
+HopEnvironmentDialog.Repo.Column.Password.Tooltip=Stored obfuscated in the
environment file, which is not encryption. Environment files are meant to be
shared: prefer a variable like $'{'MY_TOKEN'}' or the
HOP_MARKETPLACE_<ID>_PASSWORD environment variable.
HopEnvironmentDialog.Repo.Column.Auth=Auth
HopEnvironmentDialog.Repo.Edit.Add=Add repository
HopEnvironmentDialog.Repo.Edit.Edit=Edit repository
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/RepositoryPasswordStorageTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/RepositoryPasswordStorageTest.java
new file mode 100644
index 0000000000..6fb2aa7c26
--- /dev/null
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/RepositoryPasswordStorageTest.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.marketplace.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.marketplace.env.HopEnvironmentSpec;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Passwords must not reach hop-config.json (or an exported definition) in
clear text, and whatever
+ * form they are stored in must survive a round trip.
+ */
+class RepositoryPasswordStorageTest {
+
+ private static final String PLAIN = "s3cret-token";
+
+ @AfterEach
+ void restoreEnvironment() {
+ MarketplaceRepository.setEnvironmentForTesting(null);
+ System.clearProperty("MARKETPLACE_TEST_TOKEN");
+ }
+
+ /** The password as it would appear in hop-config.json. */
+ @SuppressWarnings("unchecked")
+ private static String storedPassword(MarketplaceConfig config) {
+ Map<String, Object> map = HopJson.newMapper().convertValue(config,
Map.class);
+ List<Map<String, Object>> repos = (List<Map<String, Object>>)
map.get("repositories");
+ return (String) repos.get(0).get("password");
+ }
+
+ private static MarketplaceConfig configWithPassword(String password) {
+ MarketplaceConfig config = new MarketplaceConfig();
+ MarketplaceRepository repo =
+ new MarketplaceRepository("acme", "https://example.org/", "user",
password);
+ config.setRepositories(new java.util.ArrayList<>(List.of(repo)));
+ return config;
+ }
+
+ @Test
+ void passwordIsObfuscatedInTheConfigFile() {
+ String stored = storedPassword(configWithPassword(PLAIN));
+ assertNotEquals(PLAIN, stored);
+ assertFalse(stored.contains(PLAIN), "clear text password found in stored
config: " + stored);
+ assertTrue(stored.startsWith("Encrypted "), "expected an encoded password,
got: " + stored);
+ }
+
+ @Test
+ void obfuscatedPasswordSurvivesASaveLoadRoundTrip() {
+ Map<String, Object> saved =
+ HopJson.newMapper().convertValue(configWithPassword(PLAIN), Map.class);
+ MarketplaceConfig reloaded = HopJson.newMapper().convertValue(saved,
MarketplaceConfig.class);
+
+ MarketplaceRepository repo = reloaded.findRepository("acme");
+ assertEquals(PLAIN, repo.getPassword());
+ assertEquals(PLAIN, repo.effectivePassword());
+ }
+
+ @Test
+ void clearTextPasswordsFromOlderConfigsStillLoad() {
+ // Written by a Hop version that had no obfuscation: read it as-is,
re-save it encoded.
+ Map<String, Object> legacy =
+ Map.of(
+ "repositories",
+ List.of(Map.of("id", "acme", "url", "https://example.org/",
"password", PLAIN)));
+
+ MarketplaceConfig loaded = HopJson.newMapper().convertValue(legacy,
MarketplaceConfig.class);
+ assertEquals(PLAIN, loaded.findRepository("acme").getPassword());
+ assertTrue(storedPassword(loaded).startsWith("Encrypted "));
+ }
+
+ @Test
+ void variablesAreStoredAsTypedAndResolvedWhenUsed() {
+ MarketplaceConfig config = configWithPassword("${MARKETPLACE_TEST_TOKEN}");
+ // A variable is a reference, not a secret: it stays readable in the file.
+ assertEquals("${MARKETPLACE_TEST_TOKEN}", storedPassword(config));
+
+ MarketplaceRepository repo = config.findRepository("acme");
+ System.setProperty("MARKETPLACE_TEST_TOKEN", PLAIN);
+ assertEquals(PLAIN, repo.effectivePassword());
+ assertTrue(repo.hasCredentials());
+ }
+
+ @Test
+ void unresolvedVariablesAreExplainedOnAuthFailure() {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("acme", "https://example.org/", "user",
"${NO_SUCH_VARIABLE}");
+ // Nothing to resolve to, so the expression is passed through rather than
silently emptied.
+ assertEquals("${NO_SUCH_VARIABLE}", repo.effectivePassword());
+ assertTrue(MarketplaceHttp.authHint(401, repo).contains("variable that is
not set"));
+ }
+
+ @Test
+ void exportedDefinitionsCarryAnObfuscatedPassword() {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("acme", "https://example.org/", "user",
PLAIN);
+
+ Map<String, Object> withoutPassword =
MarketplaceRepositoryDefinition.toYamlMap(repo, false);
+ assertFalse(withoutPassword.containsKey("password"));
+
+ Map<String, Object> withPassword =
MarketplaceRepositoryDefinition.toYamlMap(repo, true);
+
assertTrue(String.valueOf(withPassword.get("password")).startsWith("Encrypted
"));
+ }
+
+ @Test
+ void importedDefinitionsAcceptBothForms() throws Exception {
+ Map<String, Object> encoded =
+ Map.of(
+ "id",
+ "acme",
+ "url",
+ "https://example.org/",
+ "password",
+ MarketplaceSecrets.encode(PLAIN));
+ assertEquals(PLAIN,
MarketplaceRepositoryDefinition.fromMap(encoded).getPassword());
+
+ // Hand-written definitions are clear text; they must import unchanged.
+ Map<String, Object> plain =
+ Map.of("id", "acme", "url", "https://example.org/", "password", PLAIN);
+ assertEquals(PLAIN,
MarketplaceRepositoryDefinition.fromMap(plain).getPassword());
+ }
+
+ @Test
+ void environmentFilesObfuscatePasswordsToo() {
+ HopEnvironmentSpec.RepositoryRef ref = new
HopEnvironmentSpec.RepositoryRef();
+ ref.setId("acme");
+ ref.setUrl("https://example.org/");
+ ref.setPassword(PLAIN);
+
+ Map<String, Object> written = HopJson.newMapper().convertValue(ref,
Map.class);
+ assertTrue(String.valueOf(written.get("password")).startsWith("Encrypted
"));
+
+ HopEnvironmentSpec.RepositoryRef read =
+ HopJson.newMapper().convertValue(written,
HopEnvironmentSpec.RepositoryRef.class);
+ assertEquals(PLAIN, read.getPassword());
+ }
+}