From d16b1b0bdff4a77537f7593ceccfab03a6fe4593 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 18 Sep 2026 16:22:39 +0300 Subject: [PATCH 1/2] Encrypt in-memory secrets with AES/GCM and a fresh IV per value GuardedString and GuardedByteArray protected their contents with the same AES/CBC code as the legacy wire format: a random key, but the framework's fixed IV, so equal secrets encrypted to equal bytes and a modified ciphertext decrypted without complaint. Those secrets never leave the process, so nothing depends on their format: newRandomEncryptor() now returns an AES-256/GCM encryptor with a random IV for every encryption. EncryptorImpl keeps only the legacy wire format, which must stay byte-compatible with the .NET connector server; a test pins its output. --- .../common/security/impl/AesGcmEncryptor.java | 85 ++++++++++++++++ .../security/impl/EncryptorFactoryImpl.java | 5 +- .../common/security/impl/EncryptorImpl.java | 29 +++--- .../security/impl/EncryptorImplTests.java | 98 +++++++++++++++++++ 4 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java create mode 100644 OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/common/security/impl/EncryptorImplTests.java diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java new file mode 100644 index 00000000..128023a6 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java @@ -0,0 +1,85 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.identityconnectors.common.security.impl; + +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.spec.GCMParameterSpec; + +import org.identityconnectors.common.security.Encryptor; + +/** + * AES-256/GCM with a key generated for this instance and a fresh random IV + * for every encryption; the IV is written in front of the ciphertext. Used + * for secrets held in memory ({@code GuardedString}, {@code GuardedByteArray}), + * which never leave the process, so there is no format to stay compatible + * with. + */ +public final class AesGcmEncryptor implements Encryptor { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int KEY_BITS = 256; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + + private final Key key; + private final SecureRandom random = new SecureRandom(); + + public AesGcmEncryptor() { + try { + KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM); + generator.init(KEY_BITS); + key = generator.generateKey(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + public byte[] encrypt(byte[] bytes) { + byte[] iv = new byte[IV_BYTES]; + random.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] encrypted = cipher.doFinal(bytes); + byte[] result = new byte[IV_BYTES + encrypted.length]; + System.arraycopy(iv, 0, result, 0, IV_BYTES); + System.arraycopy(encrypted, 0, result, IV_BYTES, encrypted.length); + return result; + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + public byte[] decrypt(byte[] bytes) { + if (bytes.length < IV_BYTES) { + throw new IllegalArgumentException("Ciphertext is shorter than its IV"); + } + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES)); + return cipher.doFinal(bytes, IV_BYTES, bytes.length - IV_BYTES); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } +} diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorFactoryImpl.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorFactoryImpl.java index 801bdd4d..0d7fec80 100644 --- a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorFactoryImpl.java +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorFactoryImpl.java @@ -19,6 +19,7 @@ * enclosed by brackets [] replaced by your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" * ==================== + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.common.security.impl; @@ -30,7 +31,7 @@ public class EncryptorFactoryImpl extends EncryptorFactory { private final Encryptor defaultEncryptor; public EncryptorFactoryImpl() { - defaultEncryptor = new EncryptorImpl(true); + defaultEncryptor = new EncryptorImpl(); } @Override @@ -40,7 +41,7 @@ public Encryptor getDefaultEncryptor() { @Override public Encryptor newRandomEncryptor() { - return new EncryptorImpl(false); + return new AesGcmEncryptor(); } } diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorImpl.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorImpl.java index 17d5fa3b..2fcef550 100644 --- a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorImpl.java +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/EncryptorImpl.java @@ -20,19 +20,27 @@ * "Portions Copyrighted [year] [name of copyright owner]" * ==================== * Portions Copyrighted 2016 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems, LLC */ package org.identityconnectors.common.security.impl; import java.security.Key; import javax.crypto.Cipher; -import javax.crypto.KeyGenerator; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import org.identityconnectors.common.security.Encryptor; +/** + * The wire format of the legacy connector server protocol: AES/CBC with a + * key and IV built into the framework, so that any peer can read what + * another wrote. This is obfuscation rather than protection - the connection + * must be protected by TLS - and it must stay byte-for-byte compatible with + * the .NET connector server, which is why it is kept as is. Secrets held in + * memory use {@link AesGcmEncryptor} instead. + */ public class EncryptorImpl implements Encryptor { private static final String ALGORITHM = "AES"; @@ -52,23 +60,10 @@ public class EncryptorImpl implements Encryptor { (byte) 0x64,(byte) 0x05,(byte) 0x6A,(byte) 0xBE, }; - private Key key; - private IvParameterSpec iv; + private final Key key = new SecretKeySpec(DEFAULT_KEY_BYTES, ALGORITHM); + private final IvParameterSpec iv = new IvParameterSpec(DEFAULT_IV_BYTES); - public EncryptorImpl(boolean defaultKey) { - if (defaultKey) { - key = new SecretKeySpec(DEFAULT_KEY_BYTES, ALGORITHM); - iv = new IvParameterSpec(DEFAULT_IV_BYTES); - } else { - try { - key = KeyGenerator.getInstance(ALGORITHM).generateKey(); - iv = new IvParameterSpec(DEFAULT_IV_BYTES); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException(e); - } - } + public EncryptorImpl() { } public byte[] decrypt(byte[] bytes) { diff --git a/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/common/security/impl/EncryptorImplTests.java b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/common/security/impl/EncryptorImplTests.java new file mode 100644 index 00000000..bff6d627 --- /dev/null +++ b/OpenICF-java-framework/connector-framework-internal/src/test/java/org/identityconnectors/common/security/impl/EncryptorImplTests.java @@ -0,0 +1,98 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.identityconnectors.common.security.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.fail; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.identityconnectors.common.security.Encryptor; +import org.identityconnectors.common.security.EncryptorFactory; +import org.testng.annotations.Test; + +public class EncryptorImplTests { + + private static final byte[] SECRET = "secret".getBytes(StandardCharsets.UTF_8); + + /** + * The default encryptor is the wire format of the legacy connector server + * protocol, shared with the .NET connector server: its output for a given + * input must not change. + */ + @Test + public void defaultEncryptorKeepsLegacyWireFormat() { + Encryptor encryptor = EncryptorFactory.getInstance().getDefaultEncryptor(); + assertEquals(encryptor.encrypt(SECRET), hex("66df076267e3a421575ce6fa2ec1d5c7")); + assertEquals(encryptor.decrypt(hex("66df076267e3a421575ce6fa2ec1d5c7")), SECRET); + } + + @Test + public void randomEncryptorRoundTrips() { + Encryptor encryptor = EncryptorFactory.getInstance().newRandomEncryptor(); + assertEquals(encryptor.decrypt(encryptor.encrypt(SECRET)), SECRET); + } + + @Test + public void randomEncryptorsDoNotShareKeys() { + Encryptor one = EncryptorFactory.getInstance().newRandomEncryptor(); + Encryptor other = EncryptorFactory.getInstance().newRandomEncryptor(); + try { + byte[] decrypted = other.decrypt(one.encrypt(SECRET)); + assertFalse(Arrays.equals(decrypted, SECRET), "decrypted with another key"); + } catch (RuntimeException expected) { + // an authenticated cipher rejects the foreign key outright + } + } + + /** + * In-memory secrets: the same value must not encrypt to the same bytes + * twice, otherwise equal secrets can be spotted in a heap dump. + */ + @Test + public void randomEncryptorUsesFreshIvForEveryEncryption() { + Encryptor encryptor = EncryptorFactory.getInstance().newRandomEncryptor(); + assertFalse(Arrays.equals(encryptor.encrypt(SECRET), encryptor.encrypt(SECRET)), + "same ciphertext for the same input"); + } + + @Test + public void randomEncryptorRejectsTamperedCiphertext() { + Encryptor encryptor = EncryptorFactory.getInstance().newRandomEncryptor(); + // several blocks long, so that a flipped byte in the first block + // leaves a CBC padding check intact and only an authenticated + // cipher notices + byte[] plain = "a plaintext that spans several cipher blocks".getBytes(StandardCharsets.UTF_8); + byte[] tampered = encryptor.encrypt(plain); + tampered[tampered.length - plain.length] ^= 0x01; + try { + byte[] decrypted = encryptor.decrypt(tampered); + fail("tampered ciphertext decrypted to " + Arrays.toString(decrypted)); + } catch (RuntimeException expected) { + // authentication failure + } + } + + private static byte[] hex(String hex) { + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16); + } + return bytes; + } +} From bd9e41f3dc3a214b9144d1a4f51ada665e101907 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 18 Sep 2026 17:31:17 +0300 Subject: [PATCH 2/2] Add @Override to AesGcmEncryptor --- .../common/security/impl/AesGcmEncryptor.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java index 128023a6..9dee574a 100644 --- a/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java +++ b/OpenICF-java-framework/connector-framework-internal/src/main/java/org/identityconnectors/common/security/impl/AesGcmEncryptor.java @@ -54,6 +54,7 @@ public AesGcmEncryptor() { } } + @Override public byte[] encrypt(byte[] bytes) { byte[] iv = new byte[IV_BYTES]; random.nextBytes(iv); @@ -70,6 +71,7 @@ public byte[] encrypt(byte[] bytes) { } } + @Override public byte[] decrypt(byte[] bytes) { if (bytes.length < IV_BYTES) { throw new IllegalArgumentException("Ciphertext is shorter than its IV");