ClasspathProperties.java
package com.ziesemer.properties;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.net.URL;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Mark A. Ziesemer
* <a href="http://www.ziesemer.com"><www.ziesemer.com></a>
*/
public class ClasspathProperties{
protected static final Logger LOGGER = LoggerFactory.getLogger(ClasspathProperties.class);
protected static final String PROPS_CONFIG_PROPS = "com/ziesemer/properties/classpathProperties.local.properties";
protected static final String SECURE_PROPS_REGEX_KEY = "com.ziesemer.properties.classpathProperties.local.secureKeysPattern";
/**
* <p>
* Convenience method for {@link #loadProperties(String, Class, Properties)}, initialized with a new, empty {@link Properties}.
* </p>
*/
public static Properties loadProperties(final String resourcesName, final Class<?> baseClass) throws IOException{
return loadProperties(resourcesName, baseClass, new Properties());
}
/**
* <p>
* Loads merged properties from one or more instances of a properties file on the Java classpath.
* This implementation assumes a parent-first classloader hierarchy (most common).
* </p>
* <p>
* This implementation logs to {@link Logger} - including all loaded properties (keys & values) at the
* <code>DEBUG</code> level.
* Consumers are advised to use choose proper runtime logging configurations to prevent this logging, as appropriate.
* However, various properties can be designated as "secure", causing only a hash of the secure values (secrets) to be logged.
* (See {@link CredentialLogger#makeHasher(java.util.Map.Entry)} for details on this hashing.)
* Properties can be designated as "secure" through one of 2 methods:
* </p>
* <ol>
* <li>
* Recommended: When multiple configuration paths are included on the classpath, each path contains a property
* file named <code>{@value #PROPS_CONFIG_PROPS}</code>, containing a key/value pair named
* "<code>secureLogging</code>".
* If this file is not found, or if the key does not exist, the path is considered "secure" by default (fail-safe).
* </li>
* <li>
* Useful for when only one configuration path is used - and is only available when the above method has been
* used to configure the containing path as "non-secure":
* Each loaded property file may contain a special key/value pair named <code>{@value #SECURE_PROPS_REGEX_KEY}</code>.
* The value contains a regular expression ({@link Pattern}) that matches additional key/value pairs that are to
* be considered "secure".
* This value will be immediately removed from the loaded property map, and not logged or included in the returned
* set of properties.
* </li>
* </ol>
* <p>
* Note that without any configuration, all properties are considered "secure" by default (fail-safe).
* </p>
* @param resourcesName The file name (including possible path) to search for across the folders on the Java classpath.
* @param baseClass The base class to obtain the {@link ClassLoader} from for searching for resources.
* @param baseProperties An existing {@link Properties} instance to use, can be used to supply preset values
* (will not be overridden).
* Must not be <code>null</code>.
*/
public static Properties loadProperties(final String resourcesName, final Class<?> baseClass, final Properties baseProperties) throws IOException{
final Properties props = baseProperties;
final ClassLoader cl = baseClass.getClassLoader();
final String searchName = resolveName(resourcesName, baseClass);
LOGGER.debug("Searching for properties named \"{}\" using ClassLoader {} ({} from context of {})...",
new Object[]{searchName, cl, resourcesName, baseClass});
final Set<String> securePropNames = new HashSet<String>();
for(final Enumeration<URL> e = cl.getResources(searchName); e.hasMoreElements();){
final URL u = e.nextElement();
LOGGER.info("Loading properties from: {}", u);
String source = u.toExternalForm();
source = source.substring(0, source.length() - searchName.length());
final boolean isSecureLoc = isSecurePath(source);
// Load into a temporary Properties instance...
final Properties curProps = new Properties();
final InputStreamReader isr = new InputStreamReader(u.openStream());
try{
curProps.load(isr);
}finally{
isr.close();
}
Matcher secureKeysMatcher = null;
if(!isSecureLoc){
final String secureKeysPatStr = curProps.getProperty(SECURE_PROPS_REGEX_KEY);
if(secureKeysPatStr != null){
secureKeysMatcher = Pattern.compile(secureKeysPatStr).matcher("");
curProps.remove(SECURE_PROPS_REGEX_KEY);
}
}
// ... then only merge the properties into the parent that don't already exist in the parent.
for(final Map.Entry<?, ?> ce : curProps.entrySet()){
final String key = (String)ce.getKey();
if(!props.containsKey(key)){
props.put(key, ce.getValue());
if(isSecureLoc){
securePropNames.add(key);
}else if(secureKeysMatcher != null){
if(secureKeysMatcher.reset(key).matches()){
securePropNames.add(key);
}
}
}
}
}
if(securePropNames.isEmpty()){
LOGGER.debug("Returning properties: {}", props);
}else{
logPropertiesSecurely(props, securePropNames);
}
return props;
}
protected static boolean isSecurePath(final String path) throws IOException{
LOGGER.debug("Checking security: {}", path);
final Properties props = new Properties();
final URL u = new URL(path + PROPS_CONFIG_PROPS);
try{
final InputStreamReader isr = new InputStreamReader(u.openStream());
try{
props.load(isr);
}finally{
isr.close();
}
}catch(final FileNotFoundException fnfe){
LOGGER.warn("{} not found; assuming \"secureLogging\" as fail-safe default.", u);
return true;
}
boolean result = true;
final String secure = props.getProperty("secureLogging");
if(secure != null){
result = !"false".equals(secure);
}
LOGGER.debug("Secure path: {}", result);
return result;
}
protected static void logPropertiesSecurely(final Properties props, final Set<String> securePropNames) throws IOException{
try{
final CredentialLogger cl = new CredentialLogger();
final Map<String, ? super Object> logProps = new HashMap<String, Object>(props.size());
for(final Map.Entry<Object, Object> ce : props.entrySet()){
final String key = (String)ce.getKey();
if(securePropNames.contains(key)){
logProps.put(key, cl.makeHasher(ce));
}else{
logProps.put(key, ce.getValue());
}
}
LOGGER.debug("Returning properties (secured for output): {}", logProps);
}catch(final IOException ioe){
throw ioe;
}catch(final Exception ex){
throw new IOException(ex);
}
}
protected static class CredentialLogger{
protected final char[] hexChars = new char[]{
'0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
protected final MessageDigest md;
protected final Random random;
protected final byte[] timeBytes = toBytes(ManagementFactory.getRuntimeMXBean().getStartTime());
public CredentialLogger() throws Exception{
md = MessageDigest.getInstance("SHA-1");
random = new SecureRandom();
}
/**
* <p>
* Returns an object with an overridden {@link Object#toString()} implementation to securely output the pair value
* (secret) through hashing.
* This allows a party that is privileged to the secret to generate their own hash and compare with the output hash
* to validate that the stored value is what is expected.
* Conversely, per the theory of one-way secure hash algorithms, it is expected that an unauthorized party will not
* be able to determine the value (secret) from the produced output.
* </p>
* <p>
* For performance, this implementation does nothing until <code>toString()</code> is called.
* (Under proper logging configurations, this may appropriately never happen.)
* </p>
* <p>
* This implementation is subject to change without notice, and as such, at least without further changes to
* / review of this implementation, the output should not be parsed.
* However, the current implementation uses a SHA-1 hash / digest, and returns output similar to the following:
* </p>
* <code>
* {SHA-1 for 0x0000000000000000 + property name + property value + 0x0000000000000000 = 0x00000000000000000000000000000000.}
* </code>
* <p>
* ... where the first number is based on the JVM startup time and the second number is random bytes - both of which are used as "salt".
* The property name / key is assumed to be logged by the parent, but is included in the hash calculation for security.
* All numbers are printed in hexadecimal format.
* </p>
*/
public Object makeHasher(final Map.Entry<Object, Object> kv){
return new Object(){
@Override
public String toString(){
if(kv.getValue() == null){
return "";
}
final byte[] randomBytes = toBytes(random.nextLong());
md.reset();
md.update(timeBytes);
md.update(kv.getKey().toString().getBytes());
md.update(kv.getValue().toString().getBytes());
final byte[] hashBytes = md.digest(randomBytes);
final String s =
"{"
+ md.getAlgorithm()
+ " for 0x"
+ bytesToString(timeBytes)
+ " + property name + property value + 0x"
+ bytesToString(randomBytes)
+ " = 0x"
+ bytesToString(hashBytes)
+ ".}";
return s;
}
};
}
protected byte[] toBytes(final long x){
return ByteBuffer.allocate(8).putLong(x).array();
}
protected String bytesToString(final byte[] bytes){
final int len = bytes.length;
final char[] result = new char[len << 1];
for(int i = 0, j = 0; i < len; i++){
result[j++] = hexChars[(0xF0 & bytes[i]) >>> 4];
result[j++] = hexChars[0x0F & bytes[i]];
}
return new String(result);
}
}
/**
* <p>Modified copy of the private <code>{@link Class}.resolveName(String)</code> method.</p>
* <p>Add a package name prefix if the name is not absolute. Remove leading "/" if name is absolute.</p>
*/
protected static String resolveName(final String name, final Class<?> baseClass){
if(name == null){
return null;
}
String result = name;
if(!result.startsWith("/")){
Class<?> c = baseClass;
while(c.isArray()){
c = c.getComponentType();
}
final String baseName = c.getName();
final int index = baseName.lastIndexOf('.');
if(index != -1){
result = baseName.substring(0, index).replace('.', '/') + "/" + result;
}
}else{
result = result.substring(1);
}
return result;
}
}