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">&lt;www.ziesemer.com&gt;</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 &amp; 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;
	}
	
}