/* * Copyright (C) 2004-2008 Jive Software, 2017-2025 Ignite Realtime Foundation. All rights reserved. * * Licensed 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.jivesoftware.util; import org.dom4j.Document; import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; import java.util.Objects; import static org.junit.jupiter.api.Assertions.*; public class XMLPropertiesTest { @Test public void testAttributes() throws Exception { String xml = ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes())); assertNull(props.getAttribute("foo","bar")); xml = ""; props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes())); assertEquals(props.getAttribute("foo","bar"), "test123"); } @Test public void testGetProperty() throws Exception { XMLProperties props = XMLProperties.getNonPersistedInstance(Objects.requireNonNull(getClass().getResourceAsStream("XMLProperties.test01.xml"))); assertEquals("123", props.getProperty("foo.bar")); assertEquals("456", props.getProperty("foo.bar.baz")); assertNull(props.getProperty("foo")); assertNull(props.getProperty("nothing.something")); } @Test public void testGetChildPropertiesIterator() throws Exception { XMLProperties props = XMLProperties.getNonPersistedInstance(Objects.requireNonNull(getClass().getResourceAsStream("XMLProperties.test02.xml"))); String[] names = {"a","b","c","d"}; String[] values = {"1","2","3","4"}; String[] children = props.getChildrenProperties("foo.bar"); for (int i=0; i" + "encryptedValue" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Verify IV attribute is present String ivAttribute = props.getAttribute("encrypted.secret", "iv"); assertNotNull(ivAttribute, "IV attribute should be present in new format"); assertEquals("MTIzNDU2Nzg5MDEyMzQ1Ng==", ivAttribute); // Verify encrypted attribute is present String encryptedAttribute = props.getAttribute("encrypted.secret", "encrypted"); assertEquals("true", encryptedAttribute); // Verify the IV can be decoded from Base64 (proving it's valid Base64) byte[] iv = Base64.getDecoder().decode(ivAttribute); assertEquals(16, iv.length, "Decoded IV should be 16 bytes"); } /** * Tests that legacy encrypted properties without IV attribute are still readable. * Ensures backward compatibility: <property encrypted="true">ciphertext</property> */ @Test public void testLegacyEncryptedPropertyWithoutIV() throws Exception { // Create XML with encrypted attribute but NO IV attribute (legacy format) String xml = "" + "" + "legacyEncryptedValue" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Verify IV attribute is NOT present in legacy format String ivAttribute = props.getAttribute("encrypted.secret", "iv"); assertNull(ivAttribute, "IV attribute should NOT be present in legacy format"); // Verify encrypted attribute is still present String encryptedAttribute = props.getAttribute("encrypted.secret", "encrypted"); assertEquals("true", encryptedAttribute); // Verify the value can be read (without decryption, since we don't have JiveGlobals set up) String value = props.getProperty("encrypted.secret"); assertEquals("legacyEncryptedValue", value); } /** * Tests that legacy format (no IV) and new format (with IV) can coexist in same XML file. * This ensures smooth migration path where old and new properties work side-by-side. */ @Test public void testMixedLegacyAndNewFormatCompatibility() throws Exception { // Mix of legacy (no IV) and new (with IV) encrypted properties String xml = "" + "" + "legacyValue" + "newValue" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Verify legacy property has no IV assertNull(props.getAttribute("config.oldSecret", "iv"), "Legacy property should not have IV attribute"); assertEquals("true", props.getAttribute("config.oldSecret", "encrypted")); // Verify new property has IV assertNotNull(props.getAttribute("config.newSecret", "iv"), "New property should have IV attribute"); assertEquals("true", props.getAttribute("config.newSecret", "encrypted")); // Both should be readable assertEquals("legacyValue", props.getProperty("config.oldSecret")); assertEquals("newValue", props.getProperty("config.newSecret")); } /** * Tests that valid Base64-encoded IVs of correct length (16 bytes) are accepted. */ @Test public void testValidIVFormats() throws Exception { // Test multiple valid 16-byte IVs encoded as Base64 String[] validIVs = { "AAAAAAAAAAAAAAAAAAAAAA==", // All zeros (16 bytes) "/////////////////////w==", // All 0xFF (16 bytes) "MTIzNDU2Nzg5MDEyMzQ1Ng==", // ASCII "1234567890123456" (16 bytes) "YWJjZGVmZ2hpamtsbW5vcA==" // ASCII "abcdefghijklmnop" (16 bytes) }; for (String validIV : validIVs) { String xml = "value"; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); String ivAttribute = props.getAttribute("config.secret", "iv"); assertNotNull(ivAttribute); // Should decode without exception byte[] decodedIV = Base64.getDecoder().decode(ivAttribute); assertEquals(16, decodedIV.length, "All valid IVs should decode to 16 bytes"); } } /** * Tests that IV attribute is stored correctly when present. * This verifies getAttribute() correctly retrieves the iv attribute value. */ @Test public void testIVAttributeRetrieval() throws Exception { String expectedIV = "YWJjZGVmZ2hpamtsbW5vcA=="; // Base64 for "abcdefghijklmnop" String xml = "" + "" + "encryptedApiKey" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Retrieve IV attribute String actualIV = props.getAttribute("secure.apiKey", "iv"); assertEquals(expectedIV, actualIV, "Retrieved IV should match stored IV"); // Verify it's valid Base64 and decodes to 16 bytes byte[] decodedIV = Base64.getDecoder().decode(actualIV); assertEquals(16, decodedIV.length); assertArrayEquals("abcdefghijklmnop".getBytes(StandardCharsets.UTF_8), decodedIV); } /** * Tests that encrypting the same plaintext with different IVs produces different ciphertexts. * This verifies that random IVs are properly used in encryption. */ @Test public void testEncryptionWithDifferentIVsProducesDifferentCiphertext() throws Exception { String plaintext = "my-secret-password"; Encryptor encryptor = new AesEncryptor(); // Generate two different random IVs byte[] iv1 = new byte[16]; byte[] iv2 = new byte[16]; new java.security.SecureRandom().nextBytes(iv1); new java.security.SecureRandom().nextBytes(iv2); // Encrypt same plaintext with different IVs String ciphertext1 = encryptor.encrypt(plaintext, iv1); String ciphertext2 = encryptor.encrypt(plaintext, iv2); // Ciphertexts should be different assertNotEquals(ciphertext1, ciphertext2, "Same plaintext with different IVs should produce different ciphertext"); // Both should decrypt to same plaintext with their respective IVs assertEquals(plaintext, encryptor.decrypt(ciphertext1, iv1)); assertEquals(plaintext, encryptor.decrypt(ciphertext2, iv2)); } /** * Tests full encryption/decryption round-trip with random IV stored in XML. * This simulates the actual XMLProperties encryption workflow. */ @Test public void testEncryptionDecryptionRoundTripWithStoredIV() throws Exception { String plaintext = "sensitive-data-123"; Encryptor encryptor = new AesEncryptor(); // Generate random IV byte[] iv = new byte[16]; new java.security.SecureRandom().nextBytes(iv); // Encrypt plaintext String ciphertext = encryptor.encrypt(plaintext, iv); // Encode IV as Base64 (as would be stored in XML) String ivBase64 = Base64.getEncoder().encodeToString(iv); // Create XML with encrypted value and IV attribute String xml = "" + "" + "" + ciphertext + "" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Retrieve stored values String storedCiphertext = props.getProperty("config.secret"); String storedIV = props.getAttribute("config.secret", "iv"); // Verify values match what we stored assertEquals(ciphertext, storedCiphertext); assertEquals(ivBase64, storedIV); // Decrypt using stored IV byte[] retrievedIV = Base64.getDecoder().decode(storedIV); String decrypted = encryptor.decrypt(storedCiphertext, retrievedIV); // Verify decryption works assertEquals(plaintext, decrypted); } /** * Tests that legacy encrypted properties (no IV) can still be decrypted. * This ensures backward compatibility with properties encrypted before random IVs were implemented. */ @Test @SuppressWarnings("deprecation") public void testLegacyEncryptedPropertyDecryption() throws Exception { String plaintext = "legacy-password"; Encryptor encryptor = new AesEncryptor(); // Encrypt using deprecated method (hardcoded IV) String legacyCiphertext = encryptor.encrypt(plaintext); // Create XML with legacy format (encrypted="true" but NO iv attribute) String xml = "" + "" + "" + legacyCiphertext + "" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Retrieve stored ciphertext String storedCiphertext = props.getProperty("config.oldSecret"); assertEquals(legacyCiphertext, storedCiphertext); // Verify no IV attribute exists String ivAttribute = props.getAttribute("config.oldSecret", "iv"); assertNull(ivAttribute, "Legacy format should not have IV attribute"); // Decrypt using deprecated method (hardcoded IV) String decrypted = encryptor.decrypt(storedCiphertext); assertEquals(plaintext, decrypted); } /** * Tests that decryptPropertyValue handles corrupted IV with invalid Base64. * Should log error and return null for corrupted properties. */ @Test public void testDecryptPropertyValue_CorruptedBase64IV() throws Exception { // Create XML with corrupted IV (invalid Base64) String xml = "" + "" + "ciphertext" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); Document doc = DocumentHelper.parseText(xml); Element element = doc.getRootElement().element("config").element("secret"); // Test decryptPropertyValue helper method directly String result = props.decryptPropertyValue("config.secret", element); // Should return null for corrupted IV assertNull(result, "Should return null when IV has invalid Base64"); } /** * Tests that decryptPropertyValue handles IV with wrong length. * IV must be exactly 16 bytes for AES. */ @Test public void testDecryptPropertyValue_WrongLengthIV() throws Exception { // Create IV that's only 8 bytes (wrong length, should be 16) byte[] shortIV = new byte[8]; String shortIVBase64 = Base64.getEncoder().encodeToString(shortIV); String xml = "" + "" + "ciphertext" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); Document doc = DocumentHelper.parseText(xml); Element element = doc.getRootElement().element("config").element("secret"); // Test decryptPropertyValue helper method directly String result = props.decryptPropertyValue("config.secret", element); // Should return null for wrong-length IV assertNull(result, "Should return null when IV has wrong length"); } /** * Tests that decryptPropertyValue handles decryption failures gracefully. * Even with valid IV, corrupted ciphertext should not crash. */ @Test public void testDecryptPropertyValue_DecryptionFailure() throws Exception { // Create valid IV but corrupted ciphertext byte[] validIV = new byte[16]; new SecureRandom().nextBytes(validIV); String validIVBase64 = Base64.getEncoder().encodeToString(validIV); String xml = "" + "" + "CORRUPTED_CIPHERTEXT" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); Document doc = DocumentHelper.parseText(xml); Element element = doc.getRootElement().element("config").element("secret"); // Test decryptPropertyValue helper method directly String result = props.decryptPropertyValue("config.secret", element); // Should return null when decryption fails assertNull(result, "Should return null when decryption fails"); } /** * Tests that encryptPropertyWithNewEncryptor generates random IVs. * Multiple encryptions of the same value should produce different IVs. */ @Test public void testEncryptPropertyWithNewEncryptor_ProducesRandomIVs() throws Exception { XMLProperties props = XMLProperties.getNonPersistedInstance(); String plaintext = "test-value"; // Create two elements for encryption Document doc1 = DocumentHelper.parseText(""); Element element1 = doc1.getRootElement().element("element1"); Document doc2 = DocumentHelper.parseText(""); Element element2 = doc2.getRootElement().element("element2"); // Encrypt the same value twice using new encryptor (target key during rotation) String encrypted1 = props.encryptPropertyWithNewEncryptor(plaintext, element1); String encrypted2 = props.encryptPropertyWithNewEncryptor(plaintext, element2); // Get the generated IVs String iv1 = element1.attributeValue("iv"); String iv2 = element2.attributeValue("iv"); // Verify IVs are different (random) assertNotNull(iv1, "First IV should be generated"); assertNotNull(iv2, "Second IV should be generated"); assertNotEquals(iv1, iv2, "IVs should be different for different encryptions"); // Verify ciphertexts are different (because IVs are different) assertNotEquals(encrypted1, encrypted2, "Ciphertexts should be different when IVs are different"); // Verify encrypted attribute is set assertEquals("true", element1.attributeValue("encrypted")); assertEquals("true", element2.attributeValue("encrypted")); } /** * Tests that encryptPropertyWithCurrentEncryptor generates valid 16-byte IVs. */ @Test public void testEncryptPropertyWithCurrentEncryptor_GeneratesValid16ByteIV() throws Exception { XMLProperties props = XMLProperties.getNonPersistedInstance(); String plaintext = "test-value"; Document doc = DocumentHelper.parseText(""); Element element = doc.getRootElement().element("element"); // Encrypt using current encryptor (for list-based properties) props.encryptPropertyWithCurrentEncryptor(plaintext, element); // Get and validate IV String ivBase64 = element.attributeValue("iv"); assertNotNull(ivBase64, "IV should be generated"); // Decode and check length byte[] iv = Base64.getDecoder().decode(ivBase64); assertEquals(16, iv.length, "IV should be exactly 16 bytes"); } /** * Tests that auto-upgrade is enabled by default when system property is not set. * This verifies the secure-by-default behaviour. */ @Test public void testAutoUpgrade_EnabledByDefault() throws Exception { // Clear system property to test default behaviour String originalValue = System.getProperty("openfire.xmlproperties.encryption.autoupgrade"); try { System.clearProperty("openfire.xmlproperties.encryption.autoupgrade"); // Create XML with legacy encrypted property (no IV attribute) String xml = "" + "" + "legacyValue" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Verify legacy property has no IV initially assertNull(props.getAttribute("config.legacy", "iv"), "Legacy property should not have IV attribute initially"); // shouldAutoUpgradeProperty should return true by default (secure default) Document doc = DocumentHelper.parseText(xml); Element element = doc.getRootElement().element("config").element("legacy"); boolean shouldUpgrade = props.shouldAutoUpgradeProperty("config.legacy", element); assertTrue(shouldUpgrade, "Auto-upgrade should be enabled by default"); } finally { // Restore original system property if (originalValue != null) { System.setProperty("openfire.xmlproperties.encryption.autoupgrade", originalValue); } else { System.clearProperty("openfire.xmlproperties.encryption.autoupgrade"); } } } /** * Tests that auto-upgrade can be disabled via system property. * This allows administrators to prevent automatic migration if needed. */ @Test public void testAutoUpgrade_Disabled() throws Exception { String originalValue = System.getProperty("openfire.xmlproperties.encryption.autoupgrade"); try { // Disable auto-upgrade System.setProperty("openfire.xmlproperties.encryption.autoupgrade", "false"); // Create XML with legacy encrypted property (no IV attribute) String xml = "" + "" + "legacyValue" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Verify shouldAutoUpgradeProperty returns false Document doc = DocumentHelper.parseText(xml); Element element = doc.getRootElement().element("config").element("legacy"); boolean shouldUpgrade = props.shouldAutoUpgradeProperty("config.legacy", element); assertFalse(shouldUpgrade, "Auto-upgrade should be disabled when property is 'false'"); } finally { if (originalValue != null) { System.setProperty("openfire.xmlproperties.encryption.autoupgrade", originalValue); } else { System.clearProperty("openfire.xmlproperties.encryption.autoupgrade"); } } } /** * Tests that properties with IV attribute are not flagged for auto-upgrade. * Only legacy properties (encrypted="true" with no iv attribute) should be upgraded. */ @Test public void testAutoUpgrade_SkipsPropertiesWithIV() throws Exception { String originalValue = System.getProperty("openfire.xmlproperties.encryption.autoupgrade"); try { // Enable auto-upgrade System.setProperty("openfire.xmlproperties.encryption.autoupgrade", "true"); // Create XML with modern encrypted property (has IV attribute) String xml = "" + "" + "modernValue" + "" + ""; XMLProperties props = XMLProperties.getNonPersistedInstance(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // Verify property has IV assertNotNull(props.getAttribute("config.modern", "iv"), "Modern property should have IV attribute"); // Verify shouldAutoUpgradeProperty returns false (no upgrade needed) Document doc = DocumentHelper.parseText(xml); Element element = doc.getRootElement().element("config").element("modern"); boolean shouldUpgrade = props.shouldAutoUpgradeProperty("config.modern", element); assertFalse(shouldUpgrade, "Properties with IV should not be flagged for auto-upgrade"); } finally { if (originalValue != null) { System.setProperty("openfire.xmlproperties.encryption.autoupgrade", originalValue); } else { System.clearProperty("openfire.xmlproperties.encryption.autoupgrade"); } } } }