This is an automated email from the ASF dual-hosted git repository.

mattcasters 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 021d31072c issue #7176 : per-project AES/AES2 password encoder and Set 
password encoder action (#7497)
021d31072c is described below

commit 021d31072cffdd29c0962d24c5308fa04410947e
Author: Matt Casters <[email protected]>
AuthorDate: Sun Jul 12 16:19:59 2026 +0200

    issue #7176 : per-project AES/AES2 password encoder and Set password 
encoder action (#7497)
    
    * issue #7176 : per-project AES/AES2 password encoder and Set password 
encoder action
    
    Re-initialize the process-global two-way password encoder from project and
    environment variables on project enable, support HOP_AES_ENCODER_KEY_FILE 
for
    Kubernetes-style key mounts, and add a workflow action for an explicit 
encoder
    switch on the canvas.
    
    * issue #7176 : integration test for Set password encoder action
    
    Add main-0019-set-password-encoder.hwf under integration-tests/actions:
    Simple Evaluation of a Hop-decoded password, switch to AES2 via Set
    password encoder, then Simple Evaluation of an AES2-decoded password.
    
    * issue #7176 : stop Encr.init from requiring HopLogStore
    
    Unconditional LogChannel.GENERAL.logBasic in Encr.init crashed unit tests
    that call Encr.init before HopLogStore is initialized (common on CI
    Surefire forks). Remove success logging from Encr, guard missing-plugin
    logging, report encoder re-bind from ProjectsUtil instead, and isolate
    AES encoder tests with AfterEach restore to Hop.
---
 SECURITY.md                                        |   9 +-
 THREAT_MODEL.md                                    |  10 +-
 assemblies/debug/pom.xml                           |   6 +
 assemblies/plugins/pom.xml                         |   6 +
 assemblies/static/src/main/resources/hop-conf.bat  |   3 +
 assemblies/static/src/main/resources/hop-conf.sh   |   3 +
 .../static/src/main/resources/hop-encrypt.bat      |   3 +
 .../static/src/main/resources/hop-encrypt.sh       |   3 +
 assemblies/static/src/main/resources/hop-gui.bat   |   3 +
 assemblies/static/src/main/resources/hop-gui.sh    |   3 +
 .../static/src/main/resources/hop-import.bat       |   3 +
 assemblies/static/src/main/resources/hop-import.sh |   3 +
 assemblies/static/src/main/resources/hop-run.bat   |   3 +
 assemblies/static/src/main/resources/hop-run.sh    |   3 +
 .../static/src/main/resources/hop-search.bat       |   3 +
 assemblies/static/src/main/resources/hop-search.sh |   3 +
 .../static/src/main/resources/hop-server.bat       |   3 +
 assemblies/static/src/main/resources/hop-server.sh |   3 +
 assemblies/static/src/main/resources/hop.bat       |   3 +
 core/src/main/java/org/apache/hop/core/Const.java  |  20 ++
 .../java/org/apache/hop/core/encryption/Encr.java  |  83 ++++++-
 .../core/encryption/ITwoWayPasswordEncoder.java    |  12 +
 docker/rest.Dockerfile                             |   2 +
 docker/unified.Dockerfile                          |   4 +
 docker/web.Dockerfile                              |   2 +
 docs/hop-user-manual/modules/ROOT/nav.adoc         |   1 +
 .../pages/password/passwords/aespasswords.adoc     |  42 +++-
 .../modules/ROOT/pages/variables.adoc              |   4 +-
 .../pages/workflow/actions/setpasswordencoder.adoc |  52 ++++
 .../actions/main-0019-set-password-encoder.hwf     | 274 +++++++++++++++++++++
 plugins/actions/pom.xml                            |   1 +
 plugins/actions/setpasswordencoder/pom.xml         |  32 +++
 .../setpasswordencoder/src/assembly/assembly.xml   |  46 ++++
 .../ActionSetPasswordEncoder.java                  | 212 ++++++++++++++++
 .../ActionSetPasswordEncoderDialog.java            | 174 +++++++++++++
 .../src/main/resources/SetPasswordEncoder.svg      |  24 ++
 .../messages/messages_en_US.properties             |  32 +++
 .../src/main/resources/version.xml                 |  20 ++
 .../passwords/aes/Aes2TwoWayPasswordEncoder.java   |  27 +-
 .../hop/passwords/aes/AesEncoderKeyUtil.java       |  91 +++++++
 .../passwords/aes/AesTwoWayPasswordEncoder.java    |  25 +-
 .../aes/AesTwoWayPasswordEncoderTest.java          |  81 +++++-
 .../org/apache/hop/projects/util/ProjectsUtil.java |  24 ++
 43 files changed, 1309 insertions(+), 52 deletions(-)

diff --git a/SECURITY.md b/SECURITY.md
index 62b250b81d..ed9982f952 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -40,9 +40,12 @@ it is not a sandbox. Operators exposing Hop on a network 
should, at minimum:
 - **Enable TLS** for the Hop Server and sensitive backends (off by default), 
and
   set `javax.net.ssl.keyStore` so outbound TLS verification is not relaxed.
 - **Protect credentials at rest** with the AES2 password encoder
-  (`HOP_PASSWORD_ENCODER_PLUGIN=AES2` + `HOP_AES_ENCODER_KEY`) or a secrets
-  resolver (Vault / Azure Key Vault / Google Secret Manager); the default
-  metadata password protection is reversible obfuscation, not encryption.
+  (`HOP_PASSWORD_ENCODER_PLUGIN=AES2` + `HOP_AES_ENCODER_KEY` or
+  `HOP_AES_ENCODER_KEY_FILE`) or a secrets resolver (Vault / Azure Key Vault /
+  Google Secret Manager); the default metadata password protection is 
reversible
+  obfuscation, not encryption. Encoder settings can be supplied per project or
+  environment (re-initialized on project enable) or via the Set password 
encoder
+  workflow action.
 - **Only run pipelines/workflows from trusted authors**, and parameterize any
   variable that may carry untrusted data.
 
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index 3c04bd210f..7de2dd274f 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -164,8 +164,11 @@ where strict outbound TLS verification is required (see 
§9).
   states it is *"not really encryption … obfuscation."* Anyone with the file
   recovers the plaintext. Real confidentiality is **opt-in**: set
   `HOP_PASSWORD_ENCODER_PLUGIN=AES2` with an operator-held 
`HOP_AES_ENCODER_KEY`
-  (AES-GCM), and/or keep secrets out of metadata via a variable resolver
-  (HashiCorp Vault, Azure Key Vault, Google Secret Manager). See §9, §10, §11a.
+  or `HOP_AES_ENCODER_KEY_FILE` (AES-GCM), and/or keep secrets out of metadata
+  via a variable resolver (HashiCorp Vault, Azure Key Vault, Google Secret
+  Manager). Encoder plugin and key can be re-bound per project/environment on
+  enable (and via the Set password encoder action); the active encoder remains
+  process-global. See §9, §10, §11a.
 - **Credential leakage in logs.** No plaintext password is logged by default,
   and decrypted DB passwords travel in a JDBC `Properties` object, not the URL.
   However, resolved JDBC/REST/HTTP URLs are logged at debug/detailed level
@@ -229,7 +232,8 @@ where strict outbound TLS verification is required (see §9).
   it has none of its own. Be aware the Docker image binds `0.0.0.0:8080` with
   the default credential.
 - **Protect credentials at rest:** enable the AES2 encoder
-  (`HOP_PASSWORD_ENCODER_PLUGIN=AES2` + `HOP_AES_ENCODER_KEY`) and/or a secrets
+  (`HOP_PASSWORD_ENCODER_PLUGIN=AES2` + `HOP_AES_ENCODER_KEY` or
+  `HOP_AES_ENCODER_KEY_FILE`) and/or a secrets
   resolver (Vault / Azure Key Vault / Google Secret Manager); never commit
   default-obfuscated credentials to VCS; avoid embedding credentials in URLs.
 - **Manage connection credentials**; don't source connection config or
diff --git a/assemblies/debug/pom.xml b/assemblies/debug/pom.xml
index 7b90c9bd17..8036f69593 100644
--- a/assemblies/debug/pom.xml
+++ b/assemblies/debug/pom.xml
@@ -217,6 +217,12 @@
             <version>${project.version}</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-action-setpasswordencoder</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-action-setvariables</artifactId>
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 3943d9fdf0..d53fd7e6c5 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -222,6 +222,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-action-setpasswordencoder</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-action-setvariables</artifactId>
diff --git a/assemblies/static/src/main/resources/hop-conf.bat 
b/assemblies/static/src/main/resources/hop-conf.bat
index 933708aa5a..b9268d7103 100644
--- a/assemblies/static/src/main/resources/hop-conf.bat
+++ b/assemblies/static/src/main/resources/hop-conf.bat
@@ -66,6 +66,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=Conf
diff --git a/assemblies/static/src/main/resources/hop-conf.sh 
b/assemblies/static/src/main/resources/hop-conf.sh
index b762787e65..d6e9cd0f72 100755
--- a/assemblies/static/src/main/resources/hop-conf.sh
+++ b/assemblies/static/src/main/resources/hop-conf.sh
@@ -63,6 +63,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=Conf 
-DHOP_AUTO_CREATE_CONFIG=Y -DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop-encrypt.bat 
b/assemblies/static/src/main/resources/hop-encrypt.bat
index 8eecda5f78..d57cfc86fe 100644
--- a/assemblies/static/src/main/resources/hop-encrypt.bat
+++ b/assemblies/static/src/main/resources/hop-encrypt.bat
@@ -67,6 +67,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=GUI
diff --git a/assemblies/static/src/main/resources/hop-encrypt.sh 
b/assemblies/static/src/main/resources/hop-encrypt.sh
index c2b112d09a..c97b43da30 100755
--- a/assemblies/static/src/main/resources/hop-encrypt.sh
+++ b/assemblies/static/src/main/resources/hop-encrypt.sh
@@ -63,6 +63,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=Encrypt 
-DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop-gui.bat 
b/assemblies/static/src/main/resources/hop-gui.bat
index 59bf3473db..e5e8fba4b2 100755
--- a/assemblies/static/src/main/resources/hop-gui.bat
+++ b/assemblies/static/src/main/resources/hop-gui.bat
@@ -87,6 +87,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=GUI
diff --git a/assemblies/static/src/main/resources/hop-gui.sh 
b/assemblies/static/src/main/resources/hop-gui.sh
index 05461f7e81..ef3d194ae0 100755
--- a/assemblies/static/src/main/resources/hop-gui.sh
+++ b/assemblies/static/src/main/resources/hop-gui.sh
@@ -65,6 +65,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=GUI 
-DHOP_AUTO_CREATE_CONFIG=Y -DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop-import.bat 
b/assemblies/static/src/main/resources/hop-import.bat
index 9ba9464885..55c5917e22 100644
--- a/assemblies/static/src/main/resources/hop-import.bat
+++ b/assemblies/static/src/main/resources/hop-import.bat
@@ -67,6 +67,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=Import
diff --git a/assemblies/static/src/main/resources/hop-import.sh 
b/assemblies/static/src/main/resources/hop-import.sh
index f2a5a1e49a..dd47db757f 100755
--- a/assemblies/static/src/main/resources/hop-import.sh
+++ b/assemblies/static/src/main/resources/hop-import.sh
@@ -63,6 +63,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=Import 
-DHOP_AUTO_CREATE_CONFIG=Y -DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop-run.bat 
b/assemblies/static/src/main/resources/hop-run.bat
index 3cfb680c2d..1dd10edc50 100755
--- a/assemblies/static/src/main/resources/hop-run.bat
+++ b/assemblies/static/src/main/resources/hop-run.bat
@@ -86,6 +86,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=Run
diff --git a/assemblies/static/src/main/resources/hop-run.sh 
b/assemblies/static/src/main/resources/hop-run.sh
index 635e6bc53f..8959684f67 100755
--- a/assemblies/static/src/main/resources/hop-run.sh
+++ b/assemblies/static/src/main/resources/hop-run.sh
@@ -63,6 +63,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=Run 
-DHOP_AUTO_CREATE_CONFIG=Y -DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop-search.bat 
b/assemblies/static/src/main/resources/hop-search.bat
index 9f0f98a47c..360a3df0ee 100644
--- a/assemblies/static/src/main/resources/hop-search.bat
+++ b/assemblies/static/src/main/resources/hop-search.bat
@@ -66,6 +66,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=Search
diff --git a/assemblies/static/src/main/resources/hop-search.sh 
b/assemblies/static/src/main/resources/hop-search.sh
index 0046658907..00282a0a70 100755
--- a/assemblies/static/src/main/resources/hop-search.sh
+++ b/assemblies/static/src/main/resources/hop-search.sh
@@ -63,6 +63,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=Search 
-DHOP_AUTO_CREATE_CONFIG=Y -DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop-server.bat 
b/assemblies/static/src/main/resources/hop-server.bat
index 13244b5a67..1d39aa1527 100644
--- a/assemblies/static/src/main/resources/hop-server.bat
+++ b/assemblies/static/src/main/resources/hop-server.bat
@@ -69,6 +69,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=Server
diff --git a/assemblies/static/src/main/resources/hop-server.sh 
b/assemblies/static/src/main/resources/hop-server.sh
index 2883148f3d..02604432c2 100755
--- a/assemblies/static/src/main/resources/hop-server.sh
+++ b/assemblies/static/src/main/resources/hop-server.sh
@@ -63,6 +63,9 @@ fi
 if [ -n "${HOP_AES_ENCODER_KEY}" ]; then
   HOP_OPTIONS="${HOP_OPTIONS} -DHOP_AES_ENCODER_KEY=${HOP_AES_ENCODER_KEY}"
 fi
+if [ -n "${HOP_AES_ENCODER_KEY_FILE}" ]; then
+  HOP_OPTIONS="${HOP_OPTIONS} 
-DHOP_AES_ENCODER_KEY_FILE=${HOP_AES_ENCODER_KEY_FILE}"
+fi
 
 HOP_OPTIONS="${HOP_OPTIONS} -DHOP_PLATFORM_RUNTIME=Server 
-DHOP_AUTO_CREATE_CONFIG=Y -DHOP_PLATFORM_OS="$(uname -s)
 HOP_OPTIONS="${HOP_OPTIONS} --add-opens java.xml/jdk.xml.internal=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens 
java.base/java.lang.invoke=ALL-UNNAMED --add-opens 
java.base/java.lang.reflect=ALL-UNNAMED --add-opens 
java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED 
--add-opens java.base/java.nio=ALL-UNNAMED --add-opens 
java.base/java.util=ALL-UNNAMED --add-opens 
java.base/java.util.concurrent=ALL-UNNAMED --add-opens 
java.base/java.util.concurre [...]
diff --git a/assemblies/static/src/main/resources/hop.bat 
b/assemblies/static/src/main/resources/hop.bat
index 04be8a57d6..af3dd79b3a 100755
--- a/assemblies/static/src/main/resources/hop.bat
+++ b/assemblies/static/src/main/resources/hop.bat
@@ -77,6 +77,9 @@ if not "%HOP_PASSWORD_ENCODER_PLUGIN%"=="" (
 if not "%HOP_AES_ENCODER_KEY%"=="" (
   set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_AES_ENCODER_KEY=%HOP_AES_ENCODER_KEY%
 )
+if not "%HOP_AES_ENCODER_KEY_FILE%"=="" (
+  set HOP_OPTIONS=%HOP_OPTIONS% 
-DHOP_AES_ENCODER_KEY_FILE=%HOP_AES_ENCODER_KEY_FILE%
+)
 
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_OS=Windows
 set HOP_OPTIONS=%HOP_OPTIONS% -DHOP_PLATFORM_RUNTIME=Run
diff --git a/core/src/main/java/org/apache/hop/core/Const.java 
b/core/src/main/java/org/apache/hop/core/Const.java
index a1d5db35df..7394cd4b33 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -724,6 +724,26 @@ public class Const {
       description = "Specifies the password encoder plugin to use by ID (Hop 
is the default).")
   public static final String HOP_PASSWORD_ENCODER_PLUGIN = 
"HOP_PASSWORD_ENCODER_PLUGIN";
 
+  /**
+   * Key string used by the AES / AES2 two-way password encoders. Prefer 
supplying this via
+   * environment or project variables rather than embedding it in metadata.
+   */
+  @Variable(
+      value = "",
+      description =
+          "Key used by the AES/AES2 password encoder plugins. Can also be set 
as a system property.")
+  public static final String HOP_AES_ENCODER_KEY = "HOP_AES_ENCODER_KEY";
+
+  /**
+   * Path to a file whose contents are the AES / AES2 encoder key (for example 
a Kubernetes secret
+   * mount). Used when {@link #HOP_AES_ENCODER_KEY} is empty.
+   */
+  @Variable(
+      value = "",
+      description =
+          "Path to a file containing the AES/AES2 password encoder key. Used 
when HOP_AES_ENCODER_KEY is empty.")
+  public static final String HOP_AES_ENCODER_KEY_FILE = 
"HOP_AES_ENCODER_KEY_FILE";
+
   /**
    * The name of the Hop encryption seed environment variable for the 
HopTwoWayPasswordEncoder class
    */
diff --git a/core/src/main/java/org/apache/hop/core/encryption/Encr.java 
b/core/src/main/java/org/apache/hop/core/encryption/Encr.java
index 878569be44..93eab02ec1 100644
--- a/core/src/main/java/org/apache/hop/core/encryption/Encr.java
+++ b/core/src/main/java/org/apache/hop/core/encryption/Encr.java
@@ -20,15 +20,22 @@ package org.apache.hop.core.encryption;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.HopClientEnvironment;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
 import org.eclipse.jetty.util.security.Password;
 
 /**
  * This class handles basic encryption of passwords in Hop. Note that it's not 
really encryption,
  * it's more obfuscation. Passwords are <b>difficult</b> to read, not 
impossible.
+ *
+ * <p>The active encoder is process-global. Call {@link #init(String, 
IVariables)} or {@link
+ * #initFromVariables(IVariables)} to re-bind it (for example when enabling a 
project or running a
+ * Set password encoder action).
  */
 public class Encr {
 
@@ -38,24 +45,90 @@ public class Encr {
     // Do nothing
   }
 
+  /**
+   * Initialize the global two-way password encoder from the given plugin ID 
using the default
+   * variable space.
+   *
+   * @param encoderPluginId plugin ID (for example {@code Hop} or {@code AES2})
+   * @throws HopException when the encoder cannot be loaded or initialized
+   */
   public static void init(String encoderPluginId) throws HopException {
+    init(encoderPluginId, Variables.getADefaultVariableSpace());
+  }
+
+  /**
+   * Initialize the global two-way password encoder from the given plugin ID 
and variable space.
+   * Thread-safe: only one re-init runs at a time.
+   *
+   * @param encoderPluginId plugin ID (for example {@code Hop} or {@code AES2})
+   * @param variables variables used to resolve key material and related 
options
+   * @throws HopException when the encoder cannot be loaded or initialized
+   */
+  public static synchronized void init(String encoderPluginId, IVariables 
variables)
+      throws HopException {
     if (Utils.isEmpty(encoderPluginId)) {
       throw new HopException(
           "Unable to initialize the two way password encoder: No encoder 
plugin type specified.");
     }
+    if (variables == null) {
+      variables = Variables.getADefaultVariableSpace();
+    }
     PluginRegistry registry = PluginRegistry.getInstance();
     IPlugin plugin =
         registry.findPluginWithId(TwoWayPasswordEncoderPluginType.class, 
encoderPluginId);
+    ITwoWayPasswordEncoder newEncoder;
     if (plugin == null) {
-      LogChannel.GENERAL.logError("Unable to find plugin with ID '" + 
encoderPluginId + "'");
-      encoder = new HopTwoWayPasswordEncoder();
+      // Do not require HopLogStore: many unit tests call Encr.init without 
full environment init.
+      logSafely(
+          true, "Unable to find two-way password encoder plugin with ID '" + 
encoderPluginId + "'");
+      newEncoder = new HopTwoWayPasswordEncoder();
     } else {
-      encoder = (ITwoWayPasswordEncoder) registry.loadClass(plugin);
+      newEncoder = (ITwoWayPasswordEncoder) registry.loadClass(plugin);
     }
 
-    // Load encoder specific options...
+    // Load encoder specific options from the provided variable space...
     //
-    encoder.init();
+    newEncoder.init(variables);
+    encoder = newEncoder;
+    // Intentionally no success log here: callers with a live log channel 
(project enable, Set
+    // password encoder action) report the active plugin ID. Logging from Encr 
itself would break
+    // unit tests that initialize the encoder before HopLogStore is ready.
+  }
+
+  /**
+   * Log only when the central log store is available. Avoids {@code Central 
Log Store is not
+   * initialized} crashes during lightweight unit-test bootstrap.
+   */
+  private static void logSafely(boolean error, String message) {
+    if (!HopLogStore.isInitialized()) {
+      return;
+    }
+    if (error) {
+      LogChannel.GENERAL.logError(message);
+    } else {
+      LogChannel.GENERAL.logBasic(message);
+    }
+  }
+
+  /**
+   * Resolve the password encoder plugin ID from variables (then system 
properties, then {@code
+   * Hop}) and re-initialize the global encoder.
+   *
+   * @param variables project/environment or workflow variables
+   * @throws HopException when initialization fails
+   */
+  public static synchronized void initFromVariables(IVariables variables) 
throws HopException {
+    if (variables == null) {
+      variables = Variables.getADefaultVariableSpace();
+    }
+    String pluginId = variables.getVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN);
+    if (Utils.isEmpty(pluginId)) {
+      pluginId = System.getProperty(Const.HOP_PASSWORD_ENCODER_PLUGIN);
+    }
+    if (Utils.isEmpty(pluginId)) {
+      pluginId = "Hop";
+    }
+    init(pluginId, variables);
   }
 
   public static final String encryptPassword(String password) {
diff --git 
a/core/src/main/java/org/apache/hop/core/encryption/ITwoWayPasswordEncoder.java 
b/core/src/main/java/org/apache/hop/core/encryption/ITwoWayPasswordEncoder.java
index e0a7a0d492..fdf9cc21cd 100644
--- 
a/core/src/main/java/org/apache/hop/core/encryption/ITwoWayPasswordEncoder.java
+++ 
b/core/src/main/java/org/apache/hop/core/encryption/ITwoWayPasswordEncoder.java
@@ -18,6 +18,7 @@
 package org.apache.hop.core.encryption;
 
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.IVariables;
 
 public interface ITwoWayPasswordEncoder {
 
@@ -29,6 +30,17 @@ public interface ITwoWayPasswordEncoder {
    */
   void init() throws HopException;
 
+  /**
+   * Initialize the password encoder using the given variable space (for 
example project or
+   * environment variables). Default implementation delegates to {@link 
#init()}.
+   *
+   * @param variables variables to resolve key material and related options 
from
+   * @throws HopException when initialization fails
+   */
+  default void init(IVariables variables) throws HopException {
+    init();
+  }
+
   /**
    * Encode the raw password, include a prefix indicating the type of 
encryption used.
    *
diff --git a/docker/rest.Dockerfile b/docker/rest.Dockerfile
index 7c083b5c36..d2f541acb3 100644
--- a/docker/rest.Dockerfile
+++ b/docker/rest.Dockerfile
@@ -20,6 +20,7 @@ FROM tomcat:10-jdk21-openjdk
 LABEL maintainer="Apache Hop Team"
 ENV HOP_CONFIG_FOLDER=""
 ENV HOP_AES_ENCODER_KEY=""
+ENV HOP_AES_ENCODER_KEY_FILE=""
 ENV HOP_AUDIT_FOLDER="${CATALINA_HOME}/webapps/ROOT/audit"
 ENV HOP_CONFIG_FOLDER="${CATALINA_HOME}/webapps/ROOT/config"
 # specify the hop log level
@@ -37,6 +38,7 @@ ENV HOP_REST_CONFIG_FOLDER="/config"
 # Set TOMCAT start variables
 ENV CATALINA_OPTS='${HOP_OPTIONS} \
   -DHOP_AES_ENCODER_KEY="${HOP_AES_ENCODER_KEY}" \
+  -DHOP_AES_ENCODER_KEY_FILE="${HOP_AES_ENCODER_KEY_FILE}" \
   -DHOP_AUDIT_FOLDER="${HOP_AUDIT_FOLDER}" \
   -DHOP_CONFIG_FOLDER="${HOP_CONFIG_FOLDER}" \
   -DHOP_LOG_LEVEL="${HOP_LOG_LEVEL}" \
diff --git a/docker/unified.Dockerfile b/docker/unified.Dockerfile
index 8b59182842..e239035101 100644
--- a/docker/unified.Dockerfile
+++ b/docker/unified.Dockerfile
@@ -320,6 +320,7 @@ ARG HOP_GID=501
 # Environment variables
 ENV DEPLOYMENT_PATH=/usr/local/tomcat/webapps/ROOT
 ENV HOP_AES_ENCODER_KEY=""
+ENV HOP_AES_ENCODER_KEY_FILE=""
 ENV HOP_AUDIT_FOLDER="${CATALINA_HOME}/webapps/ROOT/audit"
 ENV HOP_CONFIG_FOLDER="${CATALINA_HOME}/webapps/ROOT/config"
 ENV HOP_LOG_LEVEL="Basic"
@@ -339,6 +340,7 @@ ENV HOP_ENVIRONMENT_CONFIG_FILE_NAME_PATHS=
 # Set TOMCAT start variables
 ENV CATALINA_OPTS='${HOP_OPTIONS} \
   -DHOP_AES_ENCODER_KEY="${HOP_AES_ENCODER_KEY}" \
+  -DHOP_AES_ENCODER_KEY_FILE="${HOP_AES_ENCODER_KEY_FILE}" \
   -DHOP_AUDIT_FOLDER="${HOP_AUDIT_FOLDER}" \
   -DHOP_CONFIG_FOLDER="${HOP_CONFIG_FOLDER}" \
   -DHOP_LOG_LEVEL="${HOP_LOG_LEVEL}" \
@@ -371,6 +373,7 @@ FROM tomcat:10-jdk21 AS rest
 # Environment variables
 ENV HOP_CONFIG_FOLDER=""
 ENV HOP_AES_ENCODER_KEY=""
+ENV HOP_AES_ENCODER_KEY_FILE=""
 ENV HOP_AUDIT_FOLDER="${CATALINA_HOME}/webapps/ROOT/audit"
 ENV HOP_CONFIG_FOLDER="${CATALINA_HOME}/webapps/ROOT/config"
 ENV HOP_LOG_LEVEL="Basic"
@@ -383,6 +386,7 @@ ENV HOP_REST_CONFIG_FOLDER="/config"
 # Set TOMCAT start variables
 ENV CATALINA_OPTS='${HOP_OPTIONS} \
   -DHOP_AES_ENCODER_KEY="${HOP_AES_ENCODER_KEY}" \
+  -DHOP_AES_ENCODER_KEY_FILE="${HOP_AES_ENCODER_KEY_FILE}" \
   -DHOP_AUDIT_FOLDER="${HOP_AUDIT_FOLDER}" \
   -DHOP_CONFIG_FOLDER="${HOP_CONFIG_FOLDER}" \
   -DHOP_LOG_LEVEL="${HOP_LOG_LEVEL}" \
diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile
index 3fbf4dd062..7dfd19c72b 100644
--- a/docker/web.Dockerfile
+++ b/docker/web.Dockerfile
@@ -25,6 +25,7 @@ ARG HOP_GID=501
 # path to where the artifacts should be deployed to
 ENV DEPLOYMENT_PATH=/usr/local/tomcat/webapps/ROOT
 ENV HOP_AES_ENCODER_KEY=""
+ENV HOP_AES_ENCODER_KEY_FILE=""
 ENV HOP_AUDIT_FOLDER="${CATALINA_HOME}/webapps/ROOT/audit"
 ENV HOP_CONFIG_FOLDER="${CATALINA_HOME}/webapps/ROOT/config"
 # specify the hop log level
@@ -52,6 +53,7 @@ ENV HOP_ENVIRONMENT_CONFIG_FILE_NAME_PATHS=
 # Set TOMCAT start variables
 ENV CATALINA_OPTS='${HOP_OPTIONS} \
   -DHOP_AES_ENCODER_KEY="${HOP_AES_ENCODER_KEY}" \
+  -DHOP_AES_ENCODER_KEY_FILE="${HOP_AES_ENCODER_KEY_FILE}" \
   -DHOP_AUDIT_FOLDER="${HOP_AUDIT_FOLDER}" \
   -DHOP_CONFIG_FOLDER="${HOP_CONFIG_FOLDER}" \
   -DHOP_LOG_LEVEL="${HOP_LOG_LEVEL}" \
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc 
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index b81ffb068b..422e970bae 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -356,6 +356,7 @@ under the License.
 *** xref:workflow/actions/runpipelinetests.adoc[Run pipeline unit tests]
 *** xref:workflow/actions/sendnagiospassivecheck.adoc[Send Nagios passive 
check]
 *** xref:workflow/actions/snmptrap.adoc[Send SNMP trap]
+*** xref:workflow/actions/setpasswordencoder.adoc[Set password encoder]
 *** xref:workflow/actions/setvariables.adoc[Set variables]
 *** xref:workflow/actions/shell.adoc[Shell]
 *** xref:workflow/actions/simpleeval.adoc[Simple evaluation]
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/password/passwords/aespasswords.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/password/passwords/aespasswords.adoc
index 84fca333ee..5f1932eb8a 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/password/passwords/aespasswords.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/password/passwords/aespasswords.adoc
@@ -26,12 +26,48 @@ WARNING: The AES prefix is deprecated since version 2.11, 
use the AES2 prefix th
 
 The AES two-way password encoder plugin allows you to encrypt and decrypt 
passwords using a provided key string.
 
+The active encoder is *process-global* for the Hop JVM. It can be selected at 
startup (system properties), re-initialized when a project or environment is 
enabled, or switched explicitly with the 
xref:workflow/actions/setpasswordencoder.adoc[Set password encoder] workflow 
action.
+
 == Configuration
 
-The configuration of this encoder is for the whole Hop environment and can as 
such only be done using system properties.  These properties are listed below 
and can also be specified as system properties with hop-run:
+=== Variables and system properties
+
+These properties can be set as system properties (`-D` / environment for hop 
scripts), as project or environment described variables, or a combination:
+
+* *`HOP_PASSWORD_ENCODER_PLUGIN`*: set this to the ID of this plugin: `AES2` 
(or `AES` for the deprecated plugin). Use `Hop` to fall back to obfuscation.
+* *`HOP_AES_ENCODER_KEY`*: the key string used to encode or decode passwords
+* *`HOP_AES_ENCODER_KEY_FILE`*: path to a file whose contents are the key 
(used when `HOP_AES_ENCODER_KEY` is empty). Useful for Kubernetes secret 
mounts, for example `${PROJECT_HOME}/.aes.key` or `/secrets/aes.key`
+
+Key material resolution order for AES/AES2:
+
+. Non-empty `HOP_AES_ENCODER_KEY` from the current variable space (or system 
property)
+. Non-empty `HOP_AES_ENCODER_KEY_FILE` (variable or system property) → read 
file contents
+
+=== Per project / environment
+
+When a project (and optional lifecycle environment) is enabled, Hop 
re-initializes the password encoder from the resulting variable space. That 
means:
+
+* Different environments can use different AES2 keys
+* Switching projects resets the encoder so a previous project's key does not 
stick
+* Omitting AES settings (or setting `HOP_PASSWORD_ENCODER_PLUGIN=Hop`) returns 
to obfuscation
+
+Example environment config variables:
+
+[source,json]
+----
+{
+  "variables": [
+    { "name": "HOP_PASSWORD_ENCODER_PLUGIN", "value": "AES2", "description": 
"Use AES2 for this environment" },
+    { "name": "HOP_AES_ENCODER_KEY_FILE", "value": "${PROJECT_HOME}/.aes.key", 
"description": "Per-project key file" }
+  ]
+}
+----
+
+Do not commit key files into source control.
+
+=== Workflow action
 
-* *`HOP_PASSWORD_ENCODER_PLUGIN`*: set this to the ID of this plugin: AES2
-* *`HOP_AES_ENCODER_KEY`*: Specify the key to use to encode or decode passwords
+For an explicit, reviewable decision on the canvas, use 
xref:workflow/actions/setpasswordencoder.adoc[Set password encoder]. It 
re-binds the same process-global encoder and must not store the raw key in the 
workflow.
 
 == Noteworthy
 
diff --git a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
index 763424ee72..a14cd93af1 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
@@ -293,7 +293,9 @@ Set to 0 to keep all rows (default)
 Set to 0 to keep all rows indefinitely (default)
 |HOP_MAX_TAB_LENGTH|-|A variable to configure Tab size
 |HOP_MAX_WORKFLOW_TRACKER_SIZE|5000|The maximum age (in minutes) of a log line 
while being kept internally by Hop. Set to 0 to keep all rows indefinitely 
(default)
-|HOP_PASSWORD_ENCODER_PLUGIN|Hop|Specifies the password encoder plugin to use 
by ID (Hop is the default).
+|HOP_PASSWORD_ENCODER_PLUGIN|Hop|Specifies the password encoder plugin to use 
by ID (Hop is the default). Re-applied when a project/environment is enabled.
+|HOP_AES_ENCODER_KEY||Key string for the AES/AES2 password encoder plugins. 
Prefer environment variables or a secrets mount over embedding in metadata.
+|HOP_AES_ENCODER_KEY_FILE||Path to a file containing the AES/AES2 encoder key. 
Used when `HOP_AES_ENCODER_KEY` is empty.
 |HOP_PIPELINE_ROWSET_SIZE|-|Name of the environment variable that contains the 
size of the pipeline rowset size. This overwrites values that you set pipeline 
settings
 |HOP_QUERY_PREVIEW_TIMEOUT|0|Default JDBC statement query timeout in 
**seconds** for database query preview (for example the initial value in the 
preview settings dialog for Table Input / Get table names, and related preview 
flows). `0` means no default from this variable. Does not apply to normal 
pipeline execution for those transforms' JDBC timeouts in the same way; use the 
preview dialog to set the timeout for a given preview run.
 |HOP_PLUGIN_CLASSES||A comma delimited list of classes to scan for plugin 
annotations
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/setpasswordencoder.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/setpasswordencoder.adoc
new file mode 100644
index 0000000000..87da6bc3b6
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/setpasswordencoder.adoc
@@ -0,0 +1,52 @@
+////
+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.
+////
+:documentationPath: /workflow/actions/
+:language: en_US
+:description: The Set password encoder action enables or switches the 
process-global two-way password encoder from a point in a workflow.
+
+= Set password encoder
+
+== Description
+
+The `Set password encoder` action enables or switches the *process-global* 
two-way password encoder from a specific point in a workflow.
+
+Use this when the choice of encoder (Hop obfuscation, AES2, and so on) should 
be a *visible decision on the canvas*, not only a setting hidden in environment 
configuration.
+
+Default configuration for a project or environment should still use 
project/environment variables (see 
xref:password/passwords/aespasswords.adoc[AES two-way password encoder]). This 
action is for explicit, auditable control in a workflow.
+
+[WARNING]
+====
+The encoder is **process-global**. Changing it affects every concurrent 
pipeline and workflow in the same JVM after this action runs. Place it before 
any step that needs the new encoder to decrypt secrets.
+====
+
+== Options
+
+[options="header", width="90%", cols="1,3"]
+|===
+|Option|Description
+|Action name|The name of the workflow action.
+|Encoder plugin ID|Plugin ID to activate: `Hop` (obfuscation), `AES2` 
(recommended), or `AES` (deprecated). Other registered two-way password encoder 
IDs can be typed as well.
+|Key variable name|Name of the variable that holds the AES/AES2 key (default 
`HOP_AES_ENCODER_KEY`). Do **not** put the raw key in the workflow definition.
+|Key file path|Optional path to a file containing the AES/AES2 key (supports 
variables). Used when the key variable is empty. Suitable for Kubernetes secret 
mounts.
+|===
+
+== Best practices
+
+* Prefer environment config variables for day-to-day keys; use this action 
when the switch must be visible in the workflow.
+* Inject the key via the environment, a secrets manager, or a key file — never 
commit keys into HWF or git.
+* Set plugin ID to `Hop` to disable AES for subsequent steps (return to 
obfuscation).
+* Mixing ciphertext prefixes (`Encrypted`, `AES`, `AES2`) is not supported for 
a single active encoder.
diff --git a/integration-tests/actions/main-0019-set-password-encoder.hwf 
b/integration-tests/actions/main-0019-set-password-encoder.hwf
new file mode 100644
index 0000000000..38d458287d
--- /dev/null
+++ b/integration-tests/actions/main-0019-set-password-encoder.hwf
@@ -0,0 +1,274 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+  <name>main-0019-set-password-encoder</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Evaluate a Hop-encoded password, switch to AES2 with Set 
password encoder, then evaluate an AES2-encoded password.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/07/11 12:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/07/11 12:00:00.000</modified_date>
+  <parameters>
+  </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>64</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Set test variables</name>
+      <description>Known Hop and AES2 ciphertexts plus the AES2 key used to 
encrypt the AES2 sample.</description>
+      <type>SET_VARIABLES</type>
+      <attributes/>
+      <replacevars>Y</replacevars>
+      <filename/>
+      <file_variable_type>JVM</file_variable_type>
+      <fields>
+        <field>
+          <variable_name>HOP_ENC</variable_name>
+          <variable_value>Encrypted 
2be98afc86aa7a181a80bab64f69dbf8b</variable_value>
+          <variable_type>CURRENT_WORKFLOW</variable_type>
+        </field>
+        <field>
+          <variable_name>HOP_PLAIN</variable_name>
+          <variable_value>SecretHop1</variable_value>
+          <variable_type>CURRENT_WORKFLOW</variable_type>
+        </field>
+        <field>
+          <variable_name>AES2_ENC</variable_name>
+          <variable_value>AES2 
5SPGXV3SEPhoDRiYIxBv+wtnSC7WQYoYU6zyJx1Y75sEzHAM</variable_value>
+          <variable_type>CURRENT_WORKFLOW</variable_type>
+        </field>
+        <field>
+          <variable_name>AES2_PLAIN</variable_name>
+          <variable_value>abcdefgh</variable_value>
+          <variable_type>CURRENT_WORKFLOW</variable_type>
+        </field>
+        <field>
+          <variable_name>HOP_AES_ENCODER_KEY</variable_name>
+          <variable_value>abcdef</variable_value>
+          <variable_type>CURRENT_WORKFLOW</variable_type>
+        </field>
+      </fields>
+      <parallel>N</parallel>
+      <xloc>224</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Decrypt Hop encoded</name>
+      <description>Decrypt the Hop-encoded password with the default Hop 
encoder into DECRYPTED.</description>
+      <type>EVAL</type>
+      <attributes/>
+      <script>// Decrypt Hop-obfuscated password using the process-global 
encoder (Hop by default)
+var enc = parent_workflow.getVariable("HOP_ENC");
+var plain = 
org.apache.hop.core.encryption.Encr.decryptPasswordOptionallyEncrypted(enc);
+parent_workflow.setVariable("DECRYPTED", plain);
+true;</script>
+      <parallel>N</parallel>
+      <xloc>400</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Simple evaluation Hop</name>
+      <description>Assert Hop decrypt of Encrypted … equals 
SecretHop1</description>
+      <type>SIMPLE_EVAL</type>
+      <attributes/>
+      <comparevalue>${HOP_PLAIN}</comparevalue>
+      <fieldtype>string</fieldtype>
+      <successbooleancondition>true</successbooleancondition>
+      <successcondition>equal</successcondition>
+      <successnumbercondition>equal</successnumbercondition>
+      <successwhenvarset>N</successwhenvarset>
+      <valuetype>variable</valuetype>
+      <variablename>DECRYPTED</variablename>
+      <parallel>N</parallel>
+      <xloc>576</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Set password encoder AES2</name>
+      <description>Switch the process-global encoder to AES2 using 
HOP_AES_ENCODER_KEY.</description>
+      <type>SET_PASSWORD_ENCODER</type>
+      <attributes/>
+      <encoder_plugin_id>AES2</encoder_plugin_id>
+      <key_variable>HOP_AES_ENCODER_KEY</key_variable>
+      <key_file/>
+      <parallel>N</parallel>
+      <xloc>768</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Decrypt AES2 encoded</name>
+      <description>Decrypt the AES2-encoded password with the newly activated 
AES2 encoder into DECRYPTED.</description>
+      <type>EVAL</type>
+      <attributes/>
+      <script>// Decrypt AES2 ciphertext now that Set password encoder 
activated AES2
+var enc = parent_workflow.getVariable("AES2_ENC");
+var plain = 
org.apache.hop.core.encryption.Encr.decryptPasswordOptionallyEncrypted(enc);
+parent_workflow.setVariable("DECRYPTED", plain);
+true;</script>
+      <parallel>N</parallel>
+      <xloc>960</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Simple evaluation AES2</name>
+      <description>Assert AES2 decrypt equals abcdefgh</description>
+      <type>SIMPLE_EVAL</type>
+      <attributes/>
+      <comparevalue>${AES2_PLAIN}</comparevalue>
+      <fieldtype>string</fieldtype>
+      <successbooleancondition>true</successbooleancondition>
+      <successcondition>equal</successcondition>
+      <successnumbercondition>equal</successnumbercondition>
+      <successwhenvarset>N</successwhenvarset>
+      <valuetype>variable</valuetype>
+      <variablename>DECRYPTED</variablename>
+      <parallel>N</parallel>
+      <xloc>1152</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Success</name>
+      <description/>
+      <type>SUCCESS</type>
+      <attributes/>
+      <parallel>N</parallel>
+      <xloc>1344</xloc>
+      <yloc>128</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort Hop eval failed</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <always_log_rows>N</always_log_rows>
+      <message>Hop-encoded password did not decrypt to the expected 
plaintext</message>
+      <parallel>N</parallel>
+      <xloc>576</xloc>
+      <yloc>272</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort AES2 eval failed</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <always_log_rows>N</always_log_rows>
+      <message>AES2-encoded password did not decrypt to the expected plaintext 
after Set password encoder</message>
+      <parallel>N</parallel>
+      <xloc>1152</xloc>
+      <yloc>272</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>Set test variables</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>Set test variables</from>
+      <to>Decrypt Hop encoded</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>Decrypt Hop encoded</from>
+      <to>Simple evaluation Hop</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>Simple evaluation Hop</from>
+      <to>Set password encoder AES2</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>Simple evaluation Hop</from>
+      <to>Abort Hop eval failed</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>Set password encoder AES2</from>
+      <to>Decrypt AES2 encoded</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>Decrypt AES2 encoded</from>
+      <to>Simple evaluation AES2</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>Simple evaluation AES2</from>
+      <to>Success</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>Simple evaluation AES2</from>
+      <to>Abort AES2 eval failed</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/plugins/actions/pom.xml b/plugins/actions/pom.xml
index a6535b4dbb..931e2c380d 100644
--- a/plugins/actions/pom.xml
+++ b/plugins/actions/pom.xml
@@ -62,6 +62,7 @@
         <module>pipeline</module>
         <module>repeat</module>
         <module>sendnagiospassivecheck</module>
+        <module>setpasswordencoder</module>
         <module>setvariables</module>
         <module>shell</module>
         <module>simpleeval</module>
diff --git a/plugins/actions/setpasswordencoder/pom.xml 
b/plugins/actions/setpasswordencoder/pom.xml
new file mode 100644
index 0000000000..d8e1f480bf
--- /dev/null
+++ b/plugins/actions/setpasswordencoder/pom.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.hop</groupId>
+        <artifactId>hop-plugins-actions</artifactId>
+        <version>2.19.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>hop-action-setpasswordencoder</artifactId>
+    <packaging>jar</packaging>
+    <name>Hop Plugins Actions Set Password Encoder</name>
+
+</project>
diff --git a/plugins/actions/setpasswordencoder/src/assembly/assembly.xml 
b/plugins/actions/setpasswordencoder/src/assembly/assembly.xml
new file mode 100644
index 0000000000..51c10618e8
--- /dev/null
+++ b/plugins/actions/setpasswordencoder/src/assembly/assembly.xml
@@ -0,0 +1,46 @@
+<!--
+  ~ 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.
+  ~
+  -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 
http://maven.apache.org/xsd/assembly-2.2.0.xsd";>
+    <id>hop-action-setpasswordencoder</id>
+    <formats>
+        <format>zip</format>
+    </formats>
+    <baseDirectory>.</baseDirectory>
+    <files>
+        <file>
+            <source>${project.basedir}/src/main/resources/version.xml</source>
+            <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+            <filtered>true</filtered>
+        </file>
+    </files>
+
+    <fileSets>
+        <fileSet>
+            <directory>${project.basedir}/src/main/samples</directory>
+            <outputDirectory>config/projects/samples/</outputDirectory>
+        </fileSet>
+    </fileSets>
+
+
+    <componentDescriptors>
+        
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+    </componentDescriptors>
+</assembly>
diff --git 
a/plugins/actions/setpasswordencoder/src/main/java/org/apache/hop/workflow/actions/setpasswordencoder/ActionSetPasswordEncoder.java
 
b/plugins/actions/setpasswordencoder/src/main/java/org/apache/hop/workflow/actions/setpasswordencoder/ActionSetPasswordEncoder.java
new file mode 100644
index 0000000000..4fc185f293
--- /dev/null
+++ 
b/plugins/actions/setpasswordencoder/src/main/java/org/apache/hop/workflow/actions/setpasswordencoder/ActionSetPasswordEncoder.java
@@ -0,0 +1,212 @@
+/*
+ * 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.workflow.actions.setpasswordencoder;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.annotations.Action;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.encryption.ITwoWayPasswordEncoder;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.json.JsonMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.workflow.action.ActionBase;
+import org.apache.hop.workflow.action.IAction;
+
+/**
+ * Explicitly enable (or switch) the process-global two-way password encoder 
from a point in a
+ * workflow. Prefer project/environment variables for default configuration; 
use this action when
+ * the decision should be visible on the canvas.
+ *
+ * <p>Does not store raw key material in the workflow. Supply a key via 
variable (default {@link
+ * Const#HOP_AES_ENCODER_KEY}) and/or a key file path ({@link 
Const#HOP_AES_ENCODER_KEY_FILE}).
+ */
+@Action(
+    id = "SET_PASSWORD_ENCODER",
+    name = "i18n::ActionSetPasswordEncoder.Name",
+    description = "i18n::ActionSetPasswordEncoder.Description",
+    image = "SetPasswordEncoder.svg",
+    categoryDescription = 
"i18n:org.apache.hop.workflow:ActionCategory.Category.Utility",
+    keywords = "i18n::ActionSetPasswordEncoder.keyword",
+    documentationUrl = "/workflow/actions/setpasswordencoder.html")
+public class ActionSetPasswordEncoder extends ActionBase implements Cloneable, 
IAction {
+  private static final Class<?> PKG = ActionSetPasswordEncoder.class;
+
+  /** Plugin ID: Hop, AES2, AES, or another registered two-way password 
encoder. */
+  @HopMetadataProperty(key = "encoder_plugin_id")
+  private String encoderPluginId;
+
+  /**
+   * Optional variable name that holds the AES key. When empty, {@link 
Const#HOP_AES_ENCODER_KEY} is
+   * used as-is from the variable space / system properties.
+   */
+  @HopMetadataProperty(key = "key_variable")
+  private String keyVariable;
+
+  /** Optional path to a file containing the AES key (supports variables). */
+  @HopMetadataProperty(key = "key_file")
+  private String keyFile;
+
+  public ActionSetPasswordEncoder(String name) {
+    super(name, "");
+    encoderPluginId = "AES2";
+    keyVariable = Const.HOP_AES_ENCODER_KEY;
+    keyFile = null;
+  }
+
+  public ActionSetPasswordEncoder() {
+    this("");
+  }
+
+  public ActionSetPasswordEncoder(ActionSetPasswordEncoder other) {
+    super(other.getName(), other.getDescription(), other.getPluginId());
+    this.encoderPluginId = other.encoderPluginId;
+    this.keyVariable = other.keyVariable;
+    this.keyFile = other.keyFile;
+  }
+
+  @Override
+  public Object clone() {
+    return new ActionSetPasswordEncoder(this);
+  }
+
+  @Override
+  public Result execute(Result result, int nr) throws HopException {
+    result.setResult(false);
+    result.setNrErrors(1);
+
+    try {
+      String pluginId = resolve(Const.NVL(encoderPluginId, "Hop"));
+      if (Utils.isEmpty(pluginId)) {
+        throw new HopException(
+            BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.Error.PluginIdMissing"));
+      }
+
+      // Optionally promote a named variable's value into HOP_AES_ENCODER_KEY 
for the encoder.
+      // Only set workflow variables — do not write key material into JVM 
system properties so
+      // project switches do not inherit a previous key via 
Variables.initializeFrom(System).
+      String keyVarName = resolve(Const.NVL(keyVariable, 
Const.HOP_AES_ENCODER_KEY));
+      if (StringUtils.isNotEmpty(keyVarName) && 
!Const.HOP_AES_ENCODER_KEY.equals(keyVarName)) {
+        String keyValue = getVariable(keyVarName);
+        if (StringUtils.isNotEmpty(keyValue)) {
+          setVariable(Const.HOP_AES_ENCODER_KEY, keyValue);
+          if (parentWorkflow != null) {
+            parentWorkflow.setVariable(Const.HOP_AES_ENCODER_KEY, keyValue);
+          }
+        }
+      }
+
+      String resolvedKeyFile = resolve(keyFile);
+      if (StringUtils.isNotEmpty(resolvedKeyFile)) {
+        setVariable(Const.HOP_AES_ENCODER_KEY_FILE, resolvedKeyFile);
+        if (parentWorkflow != null) {
+          parentWorkflow.setVariable(Const.HOP_AES_ENCODER_KEY_FILE, 
resolvedKeyFile);
+        }
+      }
+
+      setVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN, pluginId);
+      if (parentWorkflow != null) {
+        parentWorkflow.setVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN, 
pluginId);
+      }
+
+      Encr.init(pluginId, this);
+      updateMetadataProviderEncoder(Encr.getEncoder());
+
+      logBasic(
+          BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.Log.EncoderInitialized", pluginId));
+      result.setResult(true);
+      result.setNrErrors(0);
+    } catch (Exception e) {
+      logError(BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.Error.Failed"), e);
+      result.setResult(false);
+      result.setNrErrors(1);
+    }
+
+    return result;
+  }
+
+  private void updateMetadataProviderEncoder(ITwoWayPasswordEncoder encoder) {
+    if (encoder == null) {
+      return;
+    }
+    IHopMetadataProvider provider = getMetadataProvider();
+    applyEncoder(provider, encoder);
+
+    if (getParentWorkflow() != null) {
+      applyEncoder(getParentWorkflow().getMetadataProvider(), encoder);
+    }
+  }
+
+  private void applyEncoder(IHopMetadataProvider provider, 
ITwoWayPasswordEncoder encoder) {
+    if (provider == null) {
+      return;
+    }
+    if (provider instanceof MultiMetadataProvider multi) {
+      multi.setTwoWayPasswordEncoder(encoder);
+      if (multi.getProviders() != null) {
+        for (IHopMetadataProvider nested : multi.getProviders()) {
+          applyEncoder(nested, encoder);
+        }
+      }
+    } else if (provider instanceof JsonMetadataProvider json) {
+      json.setTwoWayPasswordEncoder(encoder);
+    } else if (provider instanceof MemoryMetadataProvider memory) {
+      memory.setTwoWayPasswordEncoder(encoder);
+    }
+  }
+
+  @Override
+  public boolean isEvaluation() {
+    return true;
+  }
+
+  @Override
+  public boolean isUnconditional() {
+    return false;
+  }
+
+  public String getEncoderPluginId() {
+    return encoderPluginId;
+  }
+
+  public void setEncoderPluginId(String encoderPluginId) {
+    this.encoderPluginId = encoderPluginId;
+  }
+
+  public String getKeyVariable() {
+    return keyVariable;
+  }
+
+  public void setKeyVariable(String keyVariable) {
+    this.keyVariable = keyVariable;
+  }
+
+  public String getKeyFile() {
+    return keyFile;
+  }
+
+  public void setKeyFile(String keyFile) {
+    this.keyFile = keyFile;
+  }
+}
diff --git 
a/plugins/actions/setpasswordencoder/src/main/java/org/apache/hop/workflow/actions/setpasswordencoder/ActionSetPasswordEncoderDialog.java
 
b/plugins/actions/setpasswordencoder/src/main/java/org/apache/hop/workflow/actions/setpasswordencoder/ActionSetPasswordEncoderDialog.java
new file mode 100644
index 0000000000..9460bb6139
--- /dev/null
+++ 
b/plugins/actions/setpasswordencoder/src/main/java/org/apache/hop/workflow/actions/setpasswordencoder/ActionSetPasswordEncoderDialog.java
@@ -0,0 +1,174 @@
+/*
+ * 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.workflow.actions.setpasswordencoder;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.workflow.action.ActionDialog;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.IAction;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+/** Dialog for {@link ActionSetPasswordEncoder}. */
+public class ActionSetPasswordEncoderDialog extends ActionDialog {
+  private static final Class<?> PKG = ActionSetPasswordEncoder.class;
+
+  private ActionSetPasswordEncoder action;
+  private boolean changed;
+
+  private Combo wEncoderPluginId;
+  private TextVar wKeyVariable;
+  private TextVar wKeyFile;
+
+  public ActionSetPasswordEncoderDialog(
+      Shell parent,
+      ActionSetPasswordEncoder action,
+      WorkflowMeta workflowMeta,
+      IVariables variables) {
+    super(parent, workflowMeta, variables);
+    this.action = action;
+    if (this.action.getName() == null) {
+      this.action.setName(BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.Name.Default"));
+    }
+  }
+
+  @Override
+  public IAction open() {
+    createShell(BaseMessages.getString(PKG, "ActionSetPasswordEncoder.Title"), 
action);
+    buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+    changed = action.hasChanged();
+    ModifyListener lsMod = e -> action.setChanged();
+
+    // Encoder plugin ID
+    Label wlEncoderPluginId = new Label(shell, SWT.RIGHT);
+    wlEncoderPluginId.setText(
+        BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.EncoderPluginId.Label"));
+    PropsUi.setLook(wlEncoderPluginId);
+    FormData fdlEncoderPluginId = new FormData();
+    fdlEncoderPluginId.left = new FormAttachment(0, 0);
+    fdlEncoderPluginId.right = new FormAttachment(middle, -margin);
+    fdlEncoderPluginId.top = new FormAttachment(wSpacer, margin);
+    wlEncoderPluginId.setLayoutData(fdlEncoderPluginId);
+
+    wEncoderPluginId = new Combo(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+    wEncoderPluginId.setItems(new String[] {"Hop", "AES2", "AES"});
+    PropsUi.setLook(wEncoderPluginId);
+    wEncoderPluginId.addModifyListener(lsMod);
+    FormData fdEncoderPluginId = new FormData();
+    fdEncoderPluginId.left = new FormAttachment(middle, 0);
+    fdEncoderPluginId.top = new FormAttachment(wlEncoderPluginId, 0, 
SWT.CENTER);
+    fdEncoderPluginId.right = new FormAttachment(100, 0);
+    wEncoderPluginId.setLayoutData(fdEncoderPluginId);
+
+    // Key variable name
+    Label wlKeyVariable = new Label(shell, SWT.RIGHT);
+    wlKeyVariable.setText(
+        BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.KeyVariable.Label"));
+    PropsUi.setLook(wlKeyVariable);
+    FormData fdlKeyVariable = new FormData();
+    fdlKeyVariable.left = new FormAttachment(0, 0);
+    fdlKeyVariable.top = new FormAttachment(wEncoderPluginId, margin);
+    fdlKeyVariable.right = new FormAttachment(middle, -margin);
+    wlKeyVariable.setLayoutData(fdlKeyVariable);
+
+    wKeyVariable = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    PropsUi.setLook(wKeyVariable);
+    wKeyVariable.addModifyListener(lsMod);
+    FormData fdKeyVariable = new FormData();
+    fdKeyVariable.left = new FormAttachment(middle, 0);
+    fdKeyVariable.top = new FormAttachment(wEncoderPluginId, margin);
+    fdKeyVariable.right = new FormAttachment(100, 0);
+    wKeyVariable.setLayoutData(fdKeyVariable);
+    wKeyVariable.setToolTipText(
+        BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.KeyVariable.Tooltip"));
+
+    // Key file
+    Label wlKeyFile = new Label(shell, SWT.RIGHT);
+    wlKeyFile.setText(BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.KeyFile.Label"));
+    PropsUi.setLook(wlKeyFile);
+    FormData fdlKeyFile = new FormData();
+    fdlKeyFile.left = new FormAttachment(0, 0);
+    fdlKeyFile.top = new FormAttachment(wKeyVariable, margin);
+    fdlKeyFile.right = new FormAttachment(middle, -margin);
+    wlKeyFile.setLayoutData(fdlKeyFile);
+
+    wKeyFile = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    PropsUi.setLook(wKeyFile);
+    wKeyFile.addModifyListener(lsMod);
+    FormData fdKeyFile = new FormData();
+    fdKeyFile.left = new FormAttachment(middle, 0);
+    fdKeyFile.top = new FormAttachment(wKeyVariable, margin);
+    fdKeyFile.right = new FormAttachment(100, 0);
+    wKeyFile.setLayoutData(fdKeyFile);
+    wKeyFile.setToolTipText(
+        BaseMessages.getString(PKG, 
"ActionSetPasswordEncoder.KeyFile.Tooltip"));
+
+    getData();
+    focusActionName();
+
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+
+    return action;
+  }
+
+  public void getData() {
+    wName.setText(Const.nullToEmpty(action.getName()));
+    wEncoderPluginId.setText(Const.NVL(action.getEncoderPluginId(), "AES2"));
+    wKeyVariable.setText(Const.NVL(action.getKeyVariable(), 
Const.HOP_AES_ENCODER_KEY));
+    wKeyFile.setText(Const.nullToEmpty(action.getKeyFile()));
+  }
+
+  @Override
+  protected void onActionNameModified() {
+    action.setChanged();
+  }
+
+  private void cancel() {
+    action.setChanged(changed);
+    action = null;
+    dispose();
+  }
+
+  private void ok() {
+    if (Utils.isEmpty(wName.getText())) {
+      MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+      mb.setText(BaseMessages.getString(PKG, 
"System.TransformActionNameMissing.Title"));
+      mb.setMessage(BaseMessages.getString(PKG, 
"System.ActionNameMissing.Msg"));
+      mb.open();
+      return;
+    }
+    action.setName(wName.getText());
+    action.setEncoderPluginId(wEncoderPluginId.getText());
+    action.setKeyVariable(wKeyVariable.getText());
+    action.setKeyFile(wKeyFile.getText());
+    dispose();
+  }
+}
diff --git 
a/plugins/actions/setpasswordencoder/src/main/resources/SetPasswordEncoder.svg 
b/plugins/actions/setpasswordencoder/src/main/resources/SetPasswordEncoder.svg
new file mode 100644
index 0000000000..53d9dd50bc
--- /dev/null
+++ 
b/plugins/actions/setpasswordencoder/src/main/resources/SetPasswordEncoder.svg
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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.
+-->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg"; x="0px" y="0px"
+     width="42px" height="42px" viewBox="0 0 42 42" enable-background="new 0 0 
42 42">
+  <rect x="10" y="18" width="22" height="16" rx="2" ry="2" fill="#3D6480"/>
+  <path d="M14,18 v-4 c0-3.3,2.7-6,6-6 s6,2.7,6,6 v4" fill="none" 
stroke="#3D6480" stroke-width="2.5" stroke-linecap="round"/>
+  <circle cx="21" cy="26" r="2.5" fill="#EFF9FE"/>
+  <rect x="20" y="27.5" width="2" height="4" fill="#EFF9FE"/>
+</svg>
diff --git 
a/plugins/actions/setpasswordencoder/src/main/resources/org/apache/hop/workflow/actions/setpasswordencoder/messages/messages_en_US.properties
 
b/plugins/actions/setpasswordencoder/src/main/resources/org/apache/hop/workflow/actions/setpasswordencoder/messages/messages_en_US.properties
new file mode 100644
index 0000000000..33e527601d
--- /dev/null
+++ 
b/plugins/actions/setpasswordencoder/src/main/resources/org/apache/hop/workflow/actions/setpasswordencoder/messages/messages_en_US.properties
@@ -0,0 +1,32 @@
+#
+#
+# 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.
+#
+#
+
+ActionSetPasswordEncoder.Name=Set password encoder
+ActionSetPasswordEncoder.Name.Default=Set password encoder
+ActionSetPasswordEncoder.Description=Enable or switch the process-global 
two-way password encoder from this point in the workflow
+ActionSetPasswordEncoder.keyword=password,encoder,AES,AES2,encrypt,security
+ActionSetPasswordEncoder.Title=Set password encoder
+ActionSetPasswordEncoder.EncoderPluginId.Label=Encoder plugin ID
+ActionSetPasswordEncoder.KeyVariable.Label=Key variable name
+ActionSetPasswordEncoder.KeyVariable.Tooltip=Name of the variable that holds 
the AES/AES2 key. Default is HOP_AES_ENCODER_KEY. Do not put the raw key in the 
workflow.
+ActionSetPasswordEncoder.KeyFile.Label=Key file path
+ActionSetPasswordEncoder.KeyFile.Tooltip=Optional path to a file containing 
the AES/AES2 key (supports variables). Used when the key variable is empty.
+ActionSetPasswordEncoder.Log.EncoderInitialized=Two-way password encoder 
initialized with plugin ID ''{0}'' (process-global)
+ActionSetPasswordEncoder.Error.PluginIdMissing=Encoder plugin ID is required
+ActionSetPasswordEncoder.Error.Failed=Error initializing the two-way password 
encoder
diff --git a/plugins/actions/setpasswordencoder/src/main/resources/version.xml 
b/plugins/actions/setpasswordencoder/src/main/resources/version.xml
new file mode 100644
index 0000000000..36ab20e22e
--- /dev/null
+++ b/plugins/actions/setpasswordencoder/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+
+<version>${project.version}</version>
diff --git 
a/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/Aes2TwoWayPasswordEncoder.java
 
b/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/Aes2TwoWayPasswordEncoder.java
index 28421486a4..bcfd713469 100644
--- 
a/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/Aes2TwoWayPasswordEncoder.java
+++ 
b/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/Aes2TwoWayPasswordEncoder.java
@@ -34,12 +34,13 @@ import 
org.apache.hop.core.encryption.TwoWayPasswordEncoderPlugin;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.util.StringUtil;
+import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 
 /**
  * We expect a few variables to be set for this plugin to be picked up: 1.
- * HOP_PASSWORD_ENCODER_PLUGIN set to the ID of this plugin:"AES" 2. 
HOP_AES_ENCODER_KEY set to the
- * key of your choice.
+ * HOP_PASSWORD_ENCODER_PLUGIN set to the ID of this plugin: "AES2" 2. 
HOP_AES_ENCODER_KEY set to
+ * the key of your choice, or HOP_AES_ENCODER_KEY_FILE pointing at a key file.
  */
 @TwoWayPasswordEncoderPlugin(
     id = "AES2",
@@ -55,33 +56,25 @@ public class Aes2TwoWayPasswordEncoder implements 
ITwoWayPasswordEncoder {
 
   @Override
   public void init() throws HopException {
+    init(Variables.getADefaultVariableSpace());
+  }
 
+  @Override
+  public void init(IVariables variables) throws HopException {
     try {
-      String aesKey = System.getProperty(VARIABLE_HOP_AES_ENCODER_KEY, null);
-      if (StringUtils.isEmpty(aesKey)) {
-        noKeySpecified();
-      }
-      String realAesKey = Variables.getADefaultVariableSpace().resolve(aesKey);
-      if (StringUtils.isEmpty(realAesKey)) {
-        noKeySpecified();
-      }
+      String realAesKey = AesEncoderKeyUtil.resolveKey(variables);
       byte[] key = realAesKey.getBytes(StandardCharsets.UTF_8);
       MessageDigest messageDigest = MessageDigest.getInstance("SHA-512");
       byte[] digestKey = messageDigest.digest(key);
       byte[] copiedKey = Arrays.copyOf(digestKey, 16);
       secretKeySpec = new SecretKeySpec(copiedKey, "AES");
+    } catch (HopException e) {
+      throw e;
     } catch (Exception e) {
       throw new HopException("Error initializing AES password encoder plugin", 
e);
     }
   }
 
-  private void noKeySpecified() throws HopException {
-    throw new HopException(
-        "Please specify a key to encrypt/decrypt with by setting variable "
-            + VARIABLE_HOP_AES_ENCODER_KEY
-            + " in the system properties");
-  }
-
   @Override
   public String encode(String password) {
     return encode(password, true);
diff --git 
a/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesEncoderKeyUtil.java
 
b/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesEncoderKeyUtil.java
new file mode 100644
index 0000000000..0d8360270f
--- /dev/null
+++ 
b/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesEncoderKeyUtil.java
@@ -0,0 +1,91 @@
+/*
+ * 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.passwords.aes;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+
+/**
+ * Shared key material resolution for AES / AES2 password encoders.
+ *
+ * <p>Order: {@code HOP_AES_ENCODER_KEY} (variables then system property), 
then contents of {@code
+ * HOP_AES_ENCODER_KEY_FILE}.
+ */
+final class AesEncoderKeyUtil {
+
+  private AesEncoderKeyUtil() {
+    // utility
+  }
+
+  static String resolveKey(IVariables variables) throws HopException {
+    if (variables == null) {
+      variables = Variables.getADefaultVariableSpace();
+    }
+
+    String key = 
firstNonEmpty(variables.getVariable(Const.HOP_AES_ENCODER_KEY));
+    if (StringUtils.isEmpty(key)) {
+      key = firstNonEmpty(System.getProperty(Const.HOP_AES_ENCODER_KEY));
+    }
+    if (StringUtils.isNotEmpty(key)) {
+      String resolved = variables.resolve(key);
+      if (StringUtils.isNotEmpty(resolved)) {
+        return resolved;
+      }
+    }
+
+    String keyFile = 
firstNonEmpty(variables.getVariable(Const.HOP_AES_ENCODER_KEY_FILE));
+    if (StringUtils.isEmpty(keyFile)) {
+      keyFile = 
firstNonEmpty(System.getProperty(Const.HOP_AES_ENCODER_KEY_FILE));
+    }
+    if (StringUtils.isNotEmpty(keyFile)) {
+      String resolvedPath = variables.resolve(keyFile);
+      if (StringUtils.isNotEmpty(resolvedPath)) {
+        return readKeyFile(resolvedPath);
+      }
+    }
+
+    throw new HopException(
+        "Please specify a key to encrypt/decrypt with by setting variable "
+            + Const.HOP_AES_ENCODER_KEY
+            + " or "
+            + Const.HOP_AES_ENCODER_KEY_FILE
+            + " (system properties or project/environment variables)");
+  }
+
+  private static String readKeyFile(String path) throws HopException {
+    try (InputStream inputStream = HopVfs.getInputStream(path)) {
+      byte[] bytes = inputStream.readAllBytes();
+      String content = new String(bytes, StandardCharsets.UTF_8);
+      // Trim trailing newlines common in secret files; do not trim 
leading/internal spaces.
+      return content.replaceAll("[\\r\\n]+$", "");
+    } catch (Exception e) {
+      throw new HopException("Error reading AES encoder key from file '" + 
path + "'", e);
+    }
+  }
+
+  private static String firstNonEmpty(String value) {
+    return StringUtils.isEmpty(value) ? null : value;
+  }
+}
diff --git 
a/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoder.java
 
b/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoder.java
index e360637eee..c9921c1087 100644
--- 
a/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoder.java
+++ 
b/plugins/misc/passwords/src/main/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoder.java
@@ -32,12 +32,13 @@ import 
org.apache.hop.core.encryption.TwoWayPasswordEncoderPlugin;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.util.StringUtil;
+import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 
 /**
  * @deprecated We expect a few variables to be set for this plugin to be 
picked up: 1.
  *     HOP_PASSWORD_ENCODER_PLUGIN set to the ID of this plugin:"AES" 2. 
HOP_AES_ENCODER_KEY set to
- *     the key of your choice.
+ *     the key of your choice, or HOP_AES_ENCODER_KEY_FILE pointing at a key 
file.
  */
 @TwoWayPasswordEncoderPlugin(
     id = "AES",
@@ -56,16 +57,13 @@ public class AesTwoWayPasswordEncoder implements 
ITwoWayPasswordEncoder {
 
   @Override
   public void init() throws HopException {
+    init(Variables.getADefaultVariableSpace());
+  }
 
+  @Override
+  public void init(IVariables variables) throws HopException {
     try {
-      String aesKey = System.getProperty(VARIABLE_HOP_AES_ENCODER_KEY, null);
-      if (StringUtils.isEmpty(aesKey)) {
-        noKeySpecified();
-      }
-      String realAesKey = Variables.getADefaultVariableSpace().resolve(aesKey);
-      if (StringUtils.isEmpty(realAesKey)) {
-        noKeySpecified();
-      }
+      String realAesKey = AesEncoderKeyUtil.resolveKey(variables);
       byte[] key = realAesKey.getBytes(StandardCharsets.UTF_8);
       MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
       byte[] digestKey = messageDigest.digest(key);
@@ -79,18 +77,13 @@ public class AesTwoWayPasswordEncoder implements 
ITwoWayPasswordEncoder {
 
       decryptCipher = Cipher.getInstance(AES_ALGORITHM);
       decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec);
+    } catch (HopException e) {
+      throw e;
     } catch (Exception e) {
       throw new HopException("Error initializing AES password encoder plugin", 
e);
     }
   }
 
-  private void noKeySpecified() throws HopException {
-    throw new HopException(
-        "Please specify a key to encrypt/decrypt with by setting variable "
-            + VARIABLE_HOP_AES_ENCODER_KEY
-            + " in the system properties");
-  }
-
   @Override
   public String encode(String password) {
     return encode(password, true);
diff --git 
a/plugins/misc/passwords/src/test/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoderTest.java
 
b/plugins/misc/passwords/src/test/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoderTest.java
index 226fbf36f9..35e1dca924 100644
--- 
a/plugins/misc/passwords/src/test/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoderTest.java
+++ 
b/plugins/misc/passwords/src/test/java/org/apache/hop/passwords/aes/AesTwoWayPasswordEncoderTest.java
@@ -23,16 +23,22 @@ import static 
org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.HopEnvironment;
 import org.apache.hop.core.database.DatabaseMeta;
 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.exception.HopException;
 import org.apache.hop.core.metadata.SerializableMetadataProvider;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
 import org.apache.hop.metadata.api.IHopMetadataSerializer;
 import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -40,6 +46,8 @@ class AesTwoWayPasswordEncoderTest {
 
   private ITwoWayPasswordEncoder encoder;
 
+  private static final String TEST_AES_KEY = "<TheKeyForTheseTestsHere!!>";
+
   private static final String[] TEST_PASSWORDS = {
     "MySillyButGoodPassword!", "", null, "abcd", "${DB_PASSWORD}"
   };
@@ -47,12 +55,23 @@ class AesTwoWayPasswordEncoderTest {
   @BeforeEach
   void setup() throws Exception {
     System.setProperty(Const.HOP_PASSWORD_ENCODER_PLUGIN, "AES");
-    System.setProperty(
-        AesTwoWayPasswordEncoder.VARIABLE_HOP_AES_ENCODER_KEY, 
"<TheKeyForTheseTestsHere!!>");
+    System.setProperty(AesTwoWayPasswordEncoder.VARIABLE_HOP_AES_ENCODER_KEY, 
TEST_AES_KEY);
+    System.clearProperty(Const.HOP_AES_ENCODER_KEY_FILE);
     HopEnvironment.init();
+    // HopEnvironment.init() only runs once; re-bind the encoder for each test.
+    Encr.init("AES");
     encoder = Encr.getEncoder();
   }
 
+  @AfterEach
+  void tearDown() throws Exception {
+    // Restore a default Hop encoder so other tests in the same Surefire fork 
are not polluted.
+    System.setProperty(Const.HOP_PASSWORD_ENCODER_PLUGIN, "Hop");
+    System.clearProperty(Const.HOP_AES_ENCODER_KEY);
+    System.clearProperty(Const.HOP_AES_ENCODER_KEY_FILE);
+    Encr.init("Hop");
+  }
+
   @Test
   void testInit() {
     // See if we've picked up the plugin
@@ -129,7 +148,7 @@ class AesTwoWayPasswordEncoderTest {
     String password = "My Password";
 
     String encoded1 = Encr.encryptPasswordIfNotUsingVariables(password);
-    String decoded1 = Encr.decryptPasswordOptionallyEncrypted(password);
+    String decoded1 = Encr.decryptPasswordOptionallyEncrypted(encoded1);
 
     assertEquals(password, decoded1);
 
@@ -140,10 +159,64 @@ class AesTwoWayPasswordEncoderTest {
     Encr.init("AES");
 
     String encoded2 = Encr.encryptPasswordIfNotUsingVariables(password);
-    String decoded2 = Encr.decryptPasswordOptionallyEncrypted(password);
+    String decoded2 = Encr.decryptPasswordOptionallyEncrypted(encoded2);
 
     assertEquals(password, decoded2);
 
     assertNotEquals(encoded1, encoded2);
   }
+
+  @Test
+  void testInitFromVariablesWithKey() throws Exception {
+    IVariables variables = new Variables();
+    variables.setVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN, "AES");
+    variables.setVariable(Const.HOP_AES_ENCODER_KEY, 
"VariableSpaceKeyForAesTests!!");
+    // Ensure system property key is different so we prove variables win
+    System.setProperty(Const.HOP_AES_ENCODER_KEY, 
"system-property-key-should-not-win");
+
+    Encr.initFromVariables(variables);
+    assertEquals(AesTwoWayPasswordEncoder.class, Encr.getEncoder().getClass());
+
+    String password = "VariableKeyPassword";
+    String encoded = Encr.encryptPasswordIfNotUsingVariables(password);
+    assertTrue(encoded.startsWith(AesTwoWayPasswordEncoder.AES_PREFIX));
+    assertEquals(password, Encr.decryptPasswordOptionallyEncrypted(encoded));
+  }
+
+  @Test
+  void testInitFromKeyFile() throws Exception {
+    Path keyFile = Files.createTempFile("hop-aes-key-", ".txt");
+    try {
+      Files.writeString(keyFile, "KeyFromFileForAesTests!!\n");
+      IVariables variables = new Variables();
+      variables.setVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN, "AES");
+      // Prefer key file when key var empty: clear variable/system key for 
this test
+      System.clearProperty(Const.HOP_AES_ENCODER_KEY);
+      variables.setVariable(Const.HOP_AES_ENCODER_KEY_FILE, 
keyFile.toAbsolutePath().toString());
+
+      Encr.initFromVariables(variables);
+      assertEquals(AesTwoWayPasswordEncoder.class, 
Encr.getEncoder().getClass());
+
+      String password = "FileKeyPassword";
+      String encoded = Encr.encryptPasswordIfNotUsingVariables(password);
+      assertEquals(password, Encr.decryptPasswordOptionallyEncrypted(encoded));
+    } finally {
+      Files.deleteIfExists(keyFile);
+    }
+  }
+
+  @Test
+  void testReinitToHopObfuscation() throws Exception {
+    String aesEncoded = 
Encr.encryptPasswordIfNotUsingVariables("OnlyValidWithAes");
+    assertTrue(aesEncoded.startsWith(AesTwoWayPasswordEncoder.AES_PREFIX));
+
+    IVariables variables = new Variables();
+    variables.setVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN, "Hop");
+    Encr.initFromVariables(variables);
+
+    assertEquals(HopTwoWayPasswordEncoder.class, Encr.getEncoder().getClass());
+    String hopEncoded = Encr.encryptPasswordIfNotUsingVariables("hop-pass");
+    assertTrue(hopEncoded.startsWith(Encr.PASSWORD_ENCRYPTED_PREFIX));
+    assertEquals("hop-pass", 
Encr.decryptPasswordOptionallyEncrypted(hopEncoded));
+  }
 }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
index 651d572e22..d6a6e70971 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
@@ -25,6 +25,7 @@ import org.apache.commons.vfs2.FileObject;
 import org.apache.commons.vfs2.FileSystemException;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.DbCache;
+import org.apache.hop.core.encryption.Encr;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.extension.ExtensionPointHandler;
 import org.apache.hop.core.logging.ILogChannel;
@@ -94,6 +95,29 @@ public class ProjectsUtil {
     //
     project.modifyVariables(variables, projectConfig, configurationFiles, 
environmentName);
 
+    // Re-bind the process-global two-way password encoder from 
project/environment variables
+    // (HOP_PASSWORD_ENCODER_PLUGIN, HOP_AES_ENCODER_KEY / 
HOP_AES_ENCODER_KEY_FILE). This resets
+    // AES keys between projects and allows falling back to Hop obfuscation 
when unset.
+    //
+    try {
+      Encr.initFromVariables(variables);
+      String encoderPluginId =
+          Const.NVL(
+              variables.getVariable(Const.HOP_PASSWORD_ENCODER_PLUGIN),
+              Const.NVL(System.getProperty(Const.HOP_PASSWORD_ENCODER_PLUGIN), 
"Hop"));
+      if (log.isBasic()) {
+        log.logBasic(
+            "Two-way password encoder initialized with plugin ID '"
+                + encoderPluginId
+                + "' for project '"
+                + projectName
+                + "'");
+      }
+    } catch (HopException e) {
+      throw new HopException(
+          "Error initializing the two-way password encoder for project '" + 
projectName + "'", e);
+    }
+
     // Change the metadata provider in the GUI
     //
     if (hasHopMetadataProvider != null) {


Reply via email to