/*
* Copyright (C) 2004-2008 Jive Software, 2017-2026 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 com.google.common.annotations.VisibleForTesting;
import org.apache.commons.text.StringEscapeUtils;
import org.dom4j.*;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.SAXReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.util.*;
import java.util.Base64;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Provides the ability to use simple XML property files. Each property is
* in the form X.Y.Z, which would map to an XML snippet of:
*
* <X>
* <Y>
* <Z>someValue</Z>
* </Y>
* </X>
*
* The XML file is passed in to the constructor and must be readable and
* writable. Setting property values will automatically persist those value
* to disk. The file encoding used is UTF-8.
*
* @author Derek DeMoro
* @author Iain Shigeoka
*/
public class XMLProperties {
private static final Logger Log = LoggerFactory.getLogger(XMLProperties.class);
private static final String ENCRYPTED_ATTRIBUTE = "encrypted";
private static final String IV_ATTRIBUTE = "iv";
/**
* Java system property to control automatic upgrade of legacy encrypted XML properties
* (those without random IV) to use random IV encryption.
* Set to "false" to disable auto-upgrade (enabled by default for security).
* Example: -Dopenfire.xmlproperties.encryption.autoupgrade=false
*/
private static final String XML_PROPERTY_ENCRYPTION_AUTOUPGRADE_PROPERTY = "openfire.xmlproperties.encryption.autoupgrade";
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Path file;
private final Document document;
/**
* Parsing the XML file every time we need a property is slow. Therefore,
* we use a Map to cache property values that are accessed more than once.
*/
private final Map> propertyCache = new HashMap<>();
/**
* Creates a new empty XMLProperties object. The object will only have an empty root element.
*
* Note that an instance created by this constructor cannot be used to persist changes (as it is not backed by a file).
*
* @throws IOException if an exception occurs initializing the properties
* @return an XMLProperties instance that is empty
*/
public static XMLProperties getNonPersistedInstance() throws IOException {
return new XMLProperties();
}
/**
* Creates a new empty XMLProperties object.
*
* Note that an instance created by this constructor cannot be used to persist changes (as it is not backed by a file).
*
* @throws IOException if an error occurs loading the properties.
*/
private XMLProperties() throws IOException {
file = null;
document = buildDoc(new StringReader(""));
}
/**
* Creates a new XMLProperties object with content loaded from a stream.
*
* Note that an instance created by this constructor cannot be used to persist changes (as it is not backed by a file).
*
* @param in the input stream of XML.
* @throws IOException if an exception occurs when reading the stream.
* @return an XMLProperties instance populated with data from the stream.
*/
public static XMLProperties getNonPersistedInstance(@Nonnull final InputStream in) throws IOException {
return new XMLProperties(in);
}
/**
* Loads XML properties from a stream.
*
* Note that an instance created by this constructor cannot be used to persist changes (as it is not backed by a file).
*
* @param in the input stream of XML.
* @throws IOException if an exception occurs when reading the stream.
*/
private XMLProperties(InputStream in) throws IOException {
file = null;
try (Reader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
document = buildDoc(reader);
}
}
/**
* Creates a new XMLProperties object.
*
* @param file the file that properties should be read from and written to.
* @throws IOException if an error occurs loading the properties.
*/
public XMLProperties(Path file) throws IOException {
this.file = file;
if (Files.notExists(file)) {
// Attempt to recover from this error case by seeing if the
// tmp file exists. It's possible that the rename of the
// tmp file failed the last time Jive was running,
// but that it exists now.
Path tempFile;
tempFile = file.getParent().resolve(file.getFileName() + ".tmp");
if (Files.exists(tempFile)) {
Log.error("WARNING: " + file.getFileName() + " was not found, but temp file from " +
"previous write operation was. Attempting automatic recovery." +
" Please check file for data consistency.");
Files.move(tempFile, file, StandardCopyOption.REPLACE_EXISTING);
}
// There isn't a possible way to recover from the file not
// being there, so throw an error.
else {
throw new NoSuchFileException("XML properties file does not exist: "
+ file.getFileName());
}
}
// Check read and write privs.
if (!Files.isReadable(file)) {
throw new IOException("XML properties file must be readable: " + file.getFileName());
}
if (!Files.isWritable(file)) {
throw new IOException("XML properties file must be writable: " + file.getFileName());
}
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
document = buildDoc(reader);
}
}
/**
* Returns the value of the specified property.
*
* @param name the name of the property to get.
* @return the value of the specified property.
*/
public String getProperty(String name) {
return getProperty(name, true);
}
/**
* Returns the value of the specified property.
*
* @param name the name of the property to get.
* @param ignoreEmpty Ignore empty property values (return null)
* @return the value of the specified property.
*/
public String getProperty(String name, boolean ignoreEmpty) {
String value = null;
boolean mustRewrite = false;
final Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
if (propertyCache.containsKey(name)) {
return propertyCache.get(name).orElse(null);
}
String[] propName = parsePropertyName(name);
// Search for this property by traversing down the XML hierarchy.
Element element = document.getRootElement();
for (String aPropName : propName) {
element = element.element(aPropName);
if (element == null) {
// This node doesn't match this part of the property name which
// indicates this property doesn't exist so return null.
propertyCache.put(name, Optional.empty());
return null;
}
}
// At this point, we found a matching property, so return its value.
// Empty strings are returned as null.
value = element.getTextTrim();
if (ignoreEmpty && "".equals(value)) {
propertyCache.put(name, Optional.empty());
return null;
} else {
// check to see if the property is marked as encrypted
if (JiveGlobals.isXMLPropertyEncrypted(name)) {
Attribute encrypted = element.attribute(ENCRYPTED_ATTRIBUTE);
if (encrypted != null) {
value = decryptPropertyValue(name, element);
// Check if legacy encrypted property should be auto-upgraded
if (shouldAutoUpgradeProperty(name, element)) {
mustRewrite = true;
}
} else {
// rewrite property as an encrypted value
Log.info("Rewriting XML property " + name + " as an encrypted value");
mustRewrite = true;
}
}
// Add to cache so that getting property next time is fast.
propertyCache.put(name, Optional.ofNullable(value));
return value;
}
} finally {
readLock.unlock();
// Outside read-lock: ReentrantReadWriteLock does not allow obtaining a write-lock while a read-lock is acquired.
if (mustRewrite) {
setProperty(name, value);
}
}
}
/**
* Return all values who's path matches the given property
* name as a String array, or an empty array if the if there
* are no children. This allows you to retrieve several values
* with the same property name. For example, consider the
* XML file entry:
*
* <foo>
* <bar>
* <prop>some value</prop>
* <prop>other value</prop>
* <prop>last value</prop>
* </bar>
* </foo>
*
* If you call getProperties("foo.bar.prop") will return a string array containing
* {"some value", "other value", "last value"}.
*
* @param name the name of the property to retrieve
* @param ignored unused parameter
* @return all child property values for the given node name.
*/
public List getProperties(String name, boolean ignored) {
List result = new ArrayList<>();
String[] propName = parsePropertyName(name);
boolean updateEncryption = false;
final Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
// Search for this property by traversing down the XML hierarchy,
// stopping one short.
Element element = document.getRootElement();
for (int i = 0; i < propName.length - 1; i++) {
element = element.element(propName[i]);
if (element == null) {
// This node doesn't match this part of the property name which
// indicates this property doesn't exist so return empty array.
return result;
}
}
// We found matching property, return names of children.
Iterator iter = element.elementIterator(propName[propName.length - 1]);
Element prop;
String value;
while (iter.hasNext()) {
prop = iter.next();
// Empty strings are skipped.
value = prop.getTextTrim();
if (!"".equals(value)) {
// check to see if the property is marked as encrypted
if (JiveGlobals.isXMLPropertyEncrypted(name)) {
Attribute encrypted = prop.attribute(ENCRYPTED_ATTRIBUTE);
if (encrypted != null) {
value = decryptPropertyValue(name, prop);
} else {
// rewrite property as an encrypted value
// TODO find a way to modify the Element while holding a Write lock rather than a Read lock.
prop.addAttribute(ENCRYPTED_ATTRIBUTE, "true");
updateEncryption = true;
}
}
result.add(value);
}
}
} finally {
readLock.unlock();
// Outside read-lock: ReentrantReadWriteLock does not allow obtaining a write-lock while a read-lock is acquired.
if (updateEncryption) {
Log.info("Rewriting values for XML property " + name + " using encryption");
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
saveProperties();
} finally {
writeLock.unlock();
}
}
}
return result;
}
/**
* Return all values who's path matches the given property
* name as a String array, or an empty array if the if there
* are no children. This allows you to retrieve several values
* with the same property name. For example, consider the
* XML file entry:
*
* <foo>
* <bar>
* <prop>some value</prop>
* <prop>other value</prop>
* <prop>last value</prop>
* </bar>
* </foo>
*
* If you call getProperties("foo.bar.prop") will return a string array containing
* {"some value", "other value", "last value"}.
*
* @param name the name of the property to retrieve
* @return all child property values for the given node name.
*/
public Iterator getChildProperties(String name) {
String[] propName = parsePropertyName(name);
// Search for this property by traversing down the XML hierarchy,
// stopping one short.
ArrayList props = new ArrayList<>();
final Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
Element element = document.getRootElement();
for (int i = 0; i < propName.length - 1; i++) {
element = element.element(propName[i]);
if (element == null) {
// This node doesn't match this part of the property name which
// indicates this property doesn't exist so return empty array.
return Collections.emptyIterator();
}
}
// We found matching property, return values of the children.
Iterator iter = element.elementIterator(propName[propName.length - 1]);
Element prop;
String value;
while (iter.hasNext()) {
prop = iter.next();
value = prop.getText();
// check to see if the property is marked as encrypted
if (JiveGlobals.isPropertyEncrypted(name) && Boolean.parseBoolean(prop.attribute(ENCRYPTED_ATTRIBUTE).getText())) {
value = JiveGlobals.getPropertyEncryptor().decrypt(value);
}
props.add(value);
}
} finally {
readLock.unlock();
}
return props.iterator();
}
/**
* Returns the value of the attribute of the given property name or {@code null}
* if it doesn't exist.
*
* @param name the property name to lookup - ie, "foo.bar"
* @param attribute the name of the attribute, ie "id"
* @return the value of the attribute of the given property or {@code null} if
* it doesn't exist.
*/
public String getAttribute(String name, String attribute) {
if (name == null || attribute == null) {
return null;
}
String[] propName = parsePropertyName(name);
// Search for this property by traversing down the XML hierarchy.
final Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
Element element = document.getRootElement();
for (String child : propName) {
element = element.element(child);
if (element == null) {
// This node doesn't match this part of the property name which
// indicates this property doesn't exist so return empty array.
break;
}
}
if (element != null) {
// Get its attribute values
return element.attributeValue(attribute);
}
} finally {
readLock.unlock();
}
return null;
}
/**
* Removes the given attribute from the XML document.
*
* @param name the property name to lookup - ie, "foo.bar"
* @param attribute the name of the attribute, ie "id"
* @return the value of the attribute of the given property or {@code null} if
* it did not exist.
*/
public String removeAttribute(String name, String attribute) {
if (name == null || attribute == null) {
return null;
}
String[] propName = parsePropertyName(name);
// Search for this property by traversing down the XML hierarchy.
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
Element element = document.getRootElement();
for (String child : propName) {
element = element.element(child);
if (element == null) {
// This node doesn't match this part of the property name which
// indicates this property doesn't exist so return empty array.
break;
}
}
String result = null;
if (element != null) {
// Get the attribute value and then remove the attribute
Attribute attr = element.attribute(attribute);
result = attr.getValue();
element.remove(attr);
}
return result;
} finally {
writeLock.unlock();
}
}
/**
* Sets a property to an array of values. Multiple values matching the same property
* is mapped to an XML file as multiple elements containing each value.
* For example, using the name "foo.bar.prop", and the value string array containing
* {"some value", "other value", "last value"} would produce the following XML:
*
* <foo>
* <bar>
* <prop>some value</prop>
* <prop>other value</prop>
* <prop>last value</prop>
* </bar>
* </foo>
*
*
* @param name the name of the property.
* @param values the values for the property (can be empty but not null).
*/
public void setProperties(String name, List values) {
String[] propName = parsePropertyName(name);
// Search for this property by traversing down the XML hierarchy,
// stopping one short.
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
Element element = document.getRootElement();
for (int i = 0; i < propName.length - 1; i++) {
// If we don't find this part of the property in the XML hierarchy
// we add it as a new node
if (element.element(propName[i]) == null) {
element.addElement(propName[i]);
}
element = element.element(propName[i]);
}
String childName = propName[propName.length - 1];
// We found matching property, clear all children.
List toRemove = new ArrayList<>();
Iterator iter = element.elementIterator(childName);
while (iter.hasNext()) {
toRemove.add(iter.next());
}
for (iter = toRemove.iterator(); iter.hasNext(); ) {
element.remove(iter.next());
}
// Add the new children.
for (String value : values) {
Element childElement = element.addElement(childName);
if (value.startsWith(" it = childElement.nodeIterator();
while (it.hasNext()) {
Node node = it.next();
if (node instanceof CDATA) {
childElement.remove(node);
break;
}
}
childElement.addCDATA(value.substring(9, value.length() - 3));
} else {
String propValue = value;
// check to see if the property is marked as encrypted
if (JiveGlobals.isPropertyEncrypted(name)) {
propValue = encryptPropertyWithCurrentEncryptor(value, childElement);
}
childElement.setText(propValue);
}
}
saveProperties();
} finally {
writeLock.unlock();
}
// Generate event.
Map params = new HashMap<>();
params.put("value", values);
PropertyEventDispatcher.dispatchEvent(name,
PropertyEventDispatcher.EventType.xml_property_set, params);
}
/**
* Adds the given value to the list of values represented by the property name.
* The property is created if it did not already exist.
*
* @param propertyName The name of the property list to change
* @param value The value to be added to the list
* @return True if the value was added to the list; false if the value was already present
*/
public boolean addToList(String propertyName, String value) {
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
List properties = getProperties(propertyName, true);
boolean propertyWasAdded = properties.add(value);
if (propertyWasAdded) {
setProperties(propertyName, properties);
}
return propertyWasAdded;
} finally {
writeLock.unlock();
}
}
/**
* Removes the given value from the list of values represented by the property name.
* The property is deleted if it no longer contains any values.
*
* @param propertyName The name of the property list to change
* @param value The value to be removed from the list
* @return True if the value was removed from the list; false if the value was not found
*/
public boolean removeFromList(String propertyName, String value) {
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
List properties = getProperties(propertyName, true);
boolean propertyWasRemoved = properties.remove(value);
if (propertyWasRemoved) {
setProperties(propertyName, properties);
}
return propertyWasRemoved;
} finally {
writeLock.unlock();
}
}
/**
* Returns a list of names for all properties found in the XML file.
*
* @return Names for all properties in the file
*/
public List getAllPropertyNames() {
final List propertyNames = new ArrayList<>();
final Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
propertyNames.addAll(getChildPropertyNamesFor(document.getRootElement(), ""));
} finally {
readLock.unlock();
}
// Check if each property exists, but do this outside of the read-lock (as it applies its own read-lock but may also apply a write-lock). See OF-3175.
final List result = new ArrayList<>();
for (final String propertyName : propertyNames) {
if (getProperty(propertyName) != null) {
result.add(propertyName);
}
}
return result;
}
private static List getChildPropertyNamesFor(Element parent, String parentName) {
// No need for locking: this is called only with a read-lock already acquired.
List result = new ArrayList<>();
for (Element child : parent.elements()) {
String childName = parentName + (parentName.isEmpty() ? "" : ".") + child.getName();
if (!result.contains(childName)) {
result.add(childName);
result.addAll(getChildPropertyNamesFor(child, childName));
}
}
return result;
}
/**
* Return all children property names of a parent property as a String array,
* or an empty array if the if there are no children. For example, given
* the properties {@code X.Y.A}, {@code X.Y.B}, and {@code X.Y.C}, then
* the child properties of {@code X.Y} are {@code A}, {@code B}, and
* {@code C}.
*
* @param parent the name of the parent property.
* @return all child property values for the given parent.
*/
public String[] getChildrenProperties(String parent) {
String[] propName = parsePropertyName(parent);
final Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
// Search for this property by traversing down the XML hierarchy.
Element element = document.getRootElement();
for (String aPropName : propName) {
element = element.element(aPropName);
if (element == null) {
// This node doesn't match this part of the property name which
// indicates this property doesn't exist so return empty array.
return new String[]{};
}
}
// We found matching property, return names of children.
return element.elements().stream()
.map(Node::getName)
.toArray(String[]::new);
} finally {
readLock.unlock();
}
}
/**
* Sets the value of the specified property. If the property doesn't
* currently exist, it will be automatically created.
*
* @param name the name of the property to set.
* @param value the new value for the property.
* @return {@code true} if the property was correctly saved to file, otherwise {@code false}
*/
public boolean setProperty(String name, String value) {
if (name == null) {
return false;
}
if (!StringEscapeUtils.escapeXml10(name).equals(name)) {
throw new IllegalArgumentException("Property name cannot contain XML entities.");
}
if (value == null) {
value = "";
}
String[] propName = parsePropertyName(name);
final boolean saved;
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
// Set cache correctly with prop name and value.
propertyCache.put(name, Optional.of(value));
// Search for this property by traversing down the XML hierarchy.
Element element = document.getRootElement();
for (String aPropName : propName) {
// If we don't find this part of the property in the XML hierarchy
// we add it as a new node
if (element.element(aPropName) == null) {
element.addElement(aPropName);
}
element = element.element(aPropName);
}
// Set the value of the property in this node.
if (value.startsWith(" it = element.nodeIterator();
while (it.hasNext()) {
Node node = it.next();
if (node instanceof CDATA) {
element.remove(node);
break;
}
}
element.addCDATA(value.substring(9, value.length() - 3));
} else {
String propValue = value;
// check to see if the property is marked as encrypted
if (JiveGlobals.isXMLPropertyEncrypted(name)) {
propValue = encryptPropertyWithNewEncryptor(value, element);
}
element.setText(propValue);
}
// Write the XML properties to disk
saved = saveProperties();
} finally {
writeLock.unlock();
}
// Generate event.
Map params = new HashMap<>();
params.put("value", value);
PropertyEventDispatcher.dispatchEvent(name, PropertyEventDispatcher.EventType.xml_property_set, params);
return saved;
}
/**
* Deletes the specified property.
*
* @param name the property to delete.
*/
public void deleteProperty(String name) {
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
// Remove property from cache.
propertyCache.remove(name);
String[] propName = parsePropertyName(name);
// Search for this property by traversing down the XML hierarchy.
Element element = document.getRootElement();
for (int i = 0; i < propName.length - 1; i++) {
element = element.element(propName[i]);
// Can't find the property so return.
if (element == null) {
return;
}
}
// Found the correct element to remove, so remove it...
element.remove(element.element(propName[propName.length - 1]));
if (element.elements().isEmpty()) {
element.getParent().remove(element);
}
// .. then write to disk.
saveProperties();
JiveGlobals.setPropertyEncrypted(name, false);
} finally {
writeLock.unlock();
}
// Generate event.
Map params = Collections.emptyMap();
PropertyEventDispatcher.dispatchEvent(name, PropertyEventDispatcher.EventType.xml_property_deleted, params);
}
/**
* Convenience routine to migrate an XML property into the database
* storage method. Will check for the XML property being null before
* migrating.
*
* @param name the name of the property to migrate.
*/
public void migrateProperty(String name) {
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
final String xmlPropertyValue = getProperty(name);
if (xmlPropertyValue != null) {
final String databasePropertyValue = JiveGlobals.getProperty(name);
if (databasePropertyValue == null) {
Log.debug("JiveGlobals: Migrating XML property '" + name + "' into database.");
JiveGlobals.setProperty(name, xmlPropertyValue);
if (JiveGlobals.isXMLPropertyEncrypted(name)) {
JiveGlobals.setPropertyEncrypted(name, true);
}
deleteProperty(name);
} else if (databasePropertyValue.equals(xmlPropertyValue)) {
Log.debug("JiveGlobals: Deleting duplicate XML property '" + name + "' that is already in database.");
if (JiveGlobals.isXMLPropertyEncrypted(name)) {
JiveGlobals.setPropertyEncrypted(name, true);
}
deleteProperty(name);
} else {
Log.warn("XML Property '" + name + "' differs from what is stored in the database. Please make property changes in the database instead of the configuration file.");
}
SystemProperty.getProperty(name).ifPresent(SystemProperty::migrationComplete);
}
} finally {
writeLock.unlock();
}
}
/**
* Builds the document XML model up based the given reader of XML data.
* @param in the input stream used to build the xml document
* @throws java.io.IOException thrown when an error occurs reading the input stream.
*/
private Document buildDoc(Reader in) throws IOException {
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
SAXReader xmlReader = new SAXReader();
xmlReader.setEncoding("UTF-8");
xmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
xmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false);
xmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
return xmlReader.read(in);
}
catch (Exception e) {
Log.error("Error reading XML properties", e);
throw new IOException(e.getMessage());
} finally {
writeLock.unlock();
}
}
/**
* Saves the properties to disk as an XML document. A temporary file is
* used during the writing process for maximum safety.
*
* Callers MUST hold the write-lock from {@link #readWriteLock}.
*
* @return false if the file could not be saved, otherwise true
*/
private boolean saveProperties() {
if (file == null) {
Log.error("Unable to save XML properties", new IllegalStateException("No file specified"));
return false;
}
// Write data out to a temporary file first.
final Path tempFile = file.getParent().resolve(file.getFileName() + ".tmp");
try (final Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) {
OutputFormat prettyPrinter = OutputFormat.createPrettyPrint();
XMLWriter xmlWriter = new XMLWriter(writer, prettyPrinter);
xmlWriter.write(document);
} catch (final Exception e) {
Log.error("Unable to write properties to tmpFile {}", tempFile, e);
// There were errors so abort replacing the old property file.
return false;
}
// No errors occurred, so replace the main file.
try {
Files.move(tempFile, file, StandardCopyOption.REPLACE_EXISTING);
} catch (final Exception e) {
Log.error("Error moving new property file from {} to {}:", tempFile, file, e);
return false;
}
return true;
}
/**
* Returns an array representation of the given Jive property. Jive
* properties are always in the format "prop.name.is.this" which would be
* represented as an array of four Strings.
*
* @param name the name of the Jive property.
* @return an array representation of the given Jive property.
*/
private static String[] parsePropertyName(String name) {
List propName = new ArrayList<>(5);
// Use a StringTokenizer to tokenize the property name.
StringTokenizer tokenizer = new StringTokenizer(name, ".");
while (tokenizer.hasMoreTokens()) {
propName.add(tokenizer.nextToken());
}
return propName.toArray(new String[0]);
}
/**
* Decrypts an encrypted property value, handling both new format (with IV) and legacy format (without IV).
* Package-private for testing.
*
* @param propertyName the name of the property (for logging)
* @param element the XML element containing the encrypted property value and optional IV attribute
* @return the decrypted value, or null if decryption fails
*/
@VisibleForTesting
String decryptPropertyValue(String propertyName, Element element) {
String encryptedValue = element.getTextTrim();
// Check for IV attribute (new format)
Attribute ivAttr = element.attribute(IV_ATTRIBUTE);
if (ivAttr != null) {
// New format: decrypt with IV
byte[] iv = null;
try {
iv = Base64.getDecoder().decode(ivAttr.getValue());
if (iv.length != 16) {
Log.error("Property '{}' has corrupted IV attribute: '{}'. IV must be exactly 16 bytes but got {}. This property was encrypted with a random IV but the IV is now invalid. Manual intervention required: either restore from backup or delete and re-create this property.",
propertyName, ivAttr.getValue(), iv.length);
iv = null;
}
} catch (final IllegalArgumentException e) {
Log.error("Property '{}' has corrupted IV attribute: '{}'. This property was encrypted with a random IV but the IV is now invalid Base64 or wrong length. Manual intervention required: either restore from backup or delete and re-create this property.",
propertyName, ivAttr.getValue(), e);
iv = null;
}
// Decrypt with validated IV (or null if IV was corrupted)
try {
return JiveGlobals.getPropertyEncryptor().decrypt(encryptedValue, iv);
} catch (Exception e) {
Log.error("Failed to decrypt property '{}' with IV. Manual intervention required: either restore from backup or delete and re-create this property.", propertyName, e);
return null;
}
} else {
// Legacy format: decrypt without IV
String decrypted = JiveGlobals.getPropertyEncryptor().decrypt(encryptedValue);
Log.warn("Property '{}' uses legacy encryption without IV, consider re-saving to upgrade", propertyName);
return decrypted;
}
}
/**
* Encrypts a property value with a random IV using the target/new encryptor.
* During encryption key rotation, this uses the NEW key so updated properties
* are encrypted with the target key. In normal operation (no rotation), this
* is the same as the current encryptor.
* Package-private for testing.
*
* @param value the plaintext value to encrypt
* @param element the XML element to add attributes to
* @return the encrypted value
*/
@VisibleForTesting
String encryptPropertyWithNewEncryptor(String value, Element element) {
return encryptPropertyValueWithIV(value, element, JiveGlobals.getPropertyEncryptor(true));
}
/**
* Encrypts a property value with a random IV using the current encryptor.
* During encryption key rotation, this uses the CURRENT/OLD key. This is used
* for list-based properties (setProperties) which weren't updated during the
* 2019 key rotation feature implementation (commit 589ef8b39c).
* Package-private for testing.
*
* @param value the plaintext value to encrypt
* @param element the XML element to add attributes to
* @return the encrypted value
*/
@VisibleForTesting
String encryptPropertyWithCurrentEncryptor(String value, Element element) {
return encryptPropertyValueWithIV(value, element, JiveGlobals.getPropertyEncryptor());
}
/**
* Encrypts a property value with a random IV and adds the encrypted attribute and IV to the element.
* Private helper method to avoid code duplication between encryption operations using different encryptors.
*
* @param value the plaintext value to encrypt
* @param element the XML element to add attributes to
* @param encryptor the encryptor to use (new/target encryptor or current encryptor)
* @return the encrypted value
*/
private String encryptPropertyValueWithIV(String value, Element element, Encryptor encryptor) {
// Generate random IV for this property
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
// Encrypt with random IV
String encrypted = encryptor.encrypt(value, iv);
// Store encrypted value and IV
element.addAttribute(ENCRYPTED_ATTRIBUTE, "true");
element.addAttribute(IV_ATTRIBUTE, Base64.getEncoder().encodeToString(iv));
return encrypted;
}
/**
* Checks if automatic upgrade of legacy encrypted properties is enabled.
* Defaults to true for security (auto-upgrade ON by default).
* Can be disabled by setting Java system property to false:
* -Dopenfire.xmlproperties.encryption.autoupgrade=false
*
* @return true if auto-upgrade is enabled, false otherwise
*/
@VisibleForTesting
static boolean isAutoUpgradeEnabled() {
return Boolean.parseBoolean(
System.getProperty(XML_PROPERTY_ENCRYPTION_AUTOUPGRADE_PROPERTY, "true")
);
}
/**
* Determines if a property should be auto-upgraded from legacy encryption
* (without IV) to modern encryption (with random IV).
*
* @param propertyName the name of the property
* @param element the XML element containing the property
* @return true if the property should be auto-upgraded, false otherwise
*/
@VisibleForTesting
boolean shouldAutoUpgradeProperty(String propertyName, Element element) {
// Check if property is encrypted and has no IV attribute (legacy format)
Attribute ivAttr = element.attribute(IV_ATTRIBUTE);
if (ivAttr == null && isAutoUpgradeEnabled()) {
Log.info("Auto-upgrading legacy encrypted property '{}' to use random IV", propertyName);
return true;
} else if (ivAttr == null && !isAutoUpgradeEnabled()) {
Log.warn("Legacy encrypted property '{}' was not auto-upgraded (disabled by system property)", propertyName);
}
return false;
}
public void setProperties(Map propertyMap) {
final Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
for (String propertyName : propertyMap.keySet()) {
String propertyValue = propertyMap.get(propertyName);
setProperty(propertyName, propertyValue);
}
} finally {
writeLock.unlock();
}
}
}