/*
* Copyright (c) 2013 EMC Corporation
* All Rights Reserved
*/
package com.emc.storageos.security.authentication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import java.io.Serializable;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javax.crypto.SecretKey;
import com.emc.storageos.coordinator.client.service.CoordinatorClient;
import com.emc.storageos.coordinator.common.Configuration;
import com.emc.storageos.coordinator.common.impl.ConfigurationImpl;
import com.emc.storageos.security.SerializerUtils;
import com.emc.storageos.security.SignatureHelper;
import org.apache.curator.framework.recipes.locks.InterProcessLock;
import org.springframework.util.CollectionUtils;
/**
* Class responsible for generating and providing access to signature keys for Tokens and ProxyTokens, during
* creation/encoding and validation/decoding. It is also responsible for rotating keys when older than a default of 24 hours.
* Rotation will happen on a "as needed" basis, when it is detected that the key about to be used for a new token request
* is old.
*
* This class also manages a cached object TokenKeysBundle which is used for persisting into coordinator and
* also hold the cached SecretKeys for the current and previous keys for regular tokens, and the fixed proxy token key.
*/
public class TokenKeyGenerator {
public static final String TOKEN_SIGNING_ALGO = "HmacSha1";
// utils
@Autowired
protected TokenMaxLifeValuesHolder _maxLifeValuesHolder;
protected CoordinatorClient _coordinator;
protected static SignatureHelper _signatureHelper = new SignatureHelper();
private static Logger _log = LoggerFactory.getLogger(TokenKeyGenerator.class);
// coordinator persistence path artifacts
private static final String DISTRIBUTED_KEY_TOKEN_LOCK = "tokenKeyGeneratorLock";
private static final String SIGNATURE_KEY_CONFIG = "tokenKeysConfig";
private static final String SIGNATURE_KEY_ID = "tokenKeyId";
// Inside the config, this property-key gets the "bundle" that has the actual keys inside
private static final String SIGNATURE_KEY = "tokenKeysBundleEntry";
// Fixed proxy token key id.
private static final String SIGNATURE_PROXY_KEY_ENTRY = "proxyTokenSignatureKeyEntry";
// Rotation related
private static final int DEFAULT_KEY_ROTATION_INTERVAL = 1000 * 60 * 60 * 24;
private long _keyRotationIntervalInMsecs = DEFAULT_KEY_ROTATION_INTERVAL;
// Misc. cached items.
private final String _proxyTokenKeyEntry = SIGNATURE_PROXY_KEY_ENTRY;
private TokenKeysBundle _cachedTokenKeysBundle = new TokenKeysBundle();
// setters/getters
public void setCoordinator(CoordinatorClient coordinator) {
_coordinator = coordinator;
}
public void setTokenMaxLifeValuesHolder(TokenMaxLifeValuesHolder holder) {
_maxLifeValuesHolder = holder;
}
public long getKeyRotationIntervalInMSecs() {
return _keyRotationIntervalInMsecs;
}
/**
* Pair of key id (time stamp) to SecretKey
*/
public static class KeyIdKeyPair implements Serializable {
private static final long serialVersionUID = 1L;
private String _entry;
private SecretKey _key;
public SecretKey getKey() {
return _key;
}
public String getEntry() {
return _entry;
}
public void setKey(SecretKey key) {
_key = key;
}
public void setKeyEntry(String keyEntry) {
_entry = keyEntry;
}
public KeyIdKeyPair() {
}
public KeyIdKeyPair(String entry, SecretKey key) {
_entry = entry;
_key = key;
}
@Override
public boolean equals(Object pair) {
KeyIdKeyPair p = (KeyIdKeyPair) pair;
return (p._entry.equals(_entry));
}
@Override
public int hashCode() {
return _entry.hashCode();
}
}
// --- end of embedded KeyIdKeyPair class
/**
* This nested class represents the objects that will be presisted in zookeeper.
* It holds the current keyid-secretkey pair, the previous one, and the proxy token secret key.
*/
public static class TokenKeysBundle implements Serializable {
private static final long serialVersionUID = 1L;
/**
* Factory method to create a bundle from scratch with a new proxytoken key and a new current token key
*
* @return
*/
public static TokenKeysBundle createNewTokenKeysBundle()
throws NoSuchAlgorithmException {
SecretKey proxyTokenKey = generateNewKey(TOKEN_SIGNING_ALGO);
return new TokenKeysBundle(getNewKeyPair(), proxyTokenKey);
}
/**
* Factory method to create a new bundle based on an existing bundle. The new bundle starts off
* with copying the keys from the original, then adds a new one. If the total number of keys is more
* than two, delete the oldest one.
*
* @param currentBundle
* @return
*/
public static TokenKeysBundle createNewTokenKeysBundleWithRotatedKeys(TokenKeysBundle currentBundle)
throws NoSuchAlgorithmException {
TokenKeysBundle newBundle = new TokenKeysBundle();
newBundle._cachedProxyKey = currentBundle._cachedProxyKey;
newBundle._cachedKeyPairs.addAll(currentBundle._cachedKeyPairs);
newBundle._cachedKeyPairs.add(getNewKeyPair());
if (newBundle._cachedKeyPairs.size() > 2) {
newBundle._cachedKeyPairs.remove(0);
}
return newBundle;
}
private static KeyIdKeyPair getNewKeyPair() throws NoSuchAlgorithmException {
Long now = System.currentTimeMillis();
String entry = now.toString();
_log.debug("New key entry generated: {}", entry);
return new KeyIdKeyPair(entry, generateNewKey(TOKEN_SIGNING_ALGO));
}
private TokenKeysBundle(final List<KeyIdKeyPair> cachedKeysIdKeypairs, final SecretKey proxyTokenKey) {
_cachedKeyPairs = cachedKeysIdKeypairs;
_cachedProxyKey = proxyTokenKey;
}
private TokenKeysBundle(KeyIdKeyPair cachedKeysIdKeypair, final SecretKey proxyTokenKey) {
ArrayList<KeyIdKeyPair> keys = new ArrayList<KeyIdKeyPair>();
keys.add(cachedKeysIdKeypair);
_cachedKeyPairs = keys;
_cachedProxyKey = proxyTokenKey;
}
// the list of cached keys. There will only be 2 at any time.
// Ordered from oldest to newest. (order of insertion)
private List<KeyIdKeyPair> _cachedKeyPairs = new ArrayList<KeyIdKeyPair>();
private SecretKey _cachedProxyKey = null;
public TokenKeysBundle() {
}
/**
* returns a list of the key ids currently cached.
*
* @return list of ids
*/
public List<String> getKeyEntries() {
List<String> ids = new ArrayList<String>();
for (KeyIdKeyPair pair : _cachedKeyPairs) {
ids.add(pair.getEntry());
}
return ids;
}
/**
* returns the most recent key id in the cache
*
* @return keyid
*/
public String getCurrentKeyEntry() {
if (!CollectionUtils.isEmpty(_cachedKeyPairs)) {
return _cachedKeyPairs.get(_cachedKeyPairs.size() - 1).getEntry();
}
return null;
}
/**
* Returns the cached SecretKey for a given keyid, if it exists
*
* @param keyEntry
* @return
*/
public SecretKey getKey(String keyEntry) {
int i = _cachedKeyPairs.indexOf(new KeyIdKeyPair(keyEntry, null));
if (i >= 0) {
return _cachedKeyPairs.get(i).getKey();
}
return null;
}
public SecretKey getProxyKey() {
return _cachedProxyKey;
}
}
// --- end of TokensKeysBundle class.
/**
* initializes the rotation time based on the max token life value.
*
* @return
*/
private void computeRotationTime() {
_keyRotationIntervalInMsecs = _maxLifeValuesHolder.computeRotationTimeInMSecs();
_log.info("Key rotation time in msecs: {}", _keyRotationIntervalInMsecs);
}
private final ScheduledExecutorService keyRotationExecutor = Executors.newScheduledThreadPool(1);
/**
* Initialization method to be called by authsvc. It will create the key configuration if it doesn't exist
* on first startup. Else it will just load the cache.
*
* @throws Exception
*/
public void globalInit() throws Exception {
computeRotationTime();
InterProcessLock lock = null;
try {
lock = _coordinator.getLock(DISTRIBUTED_KEY_TOKEN_LOCK);
lock.acquire();
if (!doesConfigExist()) {
TokenKeysBundle bundle = TokenKeysBundle.createNewTokenKeysBundle();
createOrUpdateBundle(bundle);
updateCachedTokenKeys(bundle);
} else {
updateCachedKeys();
_log.debug("Token keys configuration exists, loaded keys");
_log.debug("Current token key {}", _cachedTokenKeysBundle.getCurrentKeyEntry());
}
keyRotationExecutor.scheduleWithFixedDelay(
new KeyRotationThread(), 0, _keyRotationIntervalInMsecs, TimeUnit.MILLISECONDS);
} catch (Exception ex) {
_log.error("Exception during initialization of TokenKeyGenerator", ex);
} finally {
try {
if (lock != null) {
lock.release();
}
} catch (Exception ex) {
_log.error("Could not release the lock during TokenKeyGenerator.init()");
throw ex;
}
}
}
/**
* Initialization method to be called by all token validating apis. apisvc and syssvc.
* It will load the cache.
*
* @throws Exception
*/
public void cacheInit() throws Exception {
computeRotationTime();
updateCachedKeys();
}
/**
* terminates scheduled threads executor
*/
public void destroy() {
if (keyRotationExecutor != null) {
keyRotationExecutor.shutdown();
_log.debug("Shutting down key rotation thread");
}
}
/**
* Reloads the keys from coordinator into cache.
*
* @throws Exception
*/
public void updateCachedKeys() throws Exception {
TokenKeysBundle bundle = readBundle();
if (bundle != null) {
updateCachedTokenKeys(bundle);
} else {
_log.debug("Did not update cache. Keys bundle was null. Bundle may not be available yet");
// That is ok. When validating tokens, a reload of the cache will be invoked if the cache
// is empty.
}
}
/**
* Thread responsible for rotating keys
*
* */
public class KeyRotationThread implements Runnable {
@Override
public void run() {
InterProcessLock lock = null;
try {
if (!checkCurrentKeyAge()) {
_log.info("Current key in cache is old. Reloading cache.");
// We are reloading the catch and performing the check again, because
// it's possible another node already did a rotation and we are not aware of it
try {
lock = _coordinator.getLock(DISTRIBUTED_KEY_TOKEN_LOCK);
lock.acquire();
updateCachedKeys();
if (!checkCurrentKeyAge()) {
_log.info("Current key in cache is still old after reload. Rotating keys now.");
rotateKeys();
}
} finally {
if (lock != null) {
try {
lock.release();
} catch (Exception ex) {
_log.error("Could not release lock during key rotation attempt");
}
}
}
}
} catch (NumberFormatException ex) {
_log.error("Could not convert key to timestamp: {}", _cachedTokenKeysBundle.getCurrentKeyEntry(), ex);
} catch (Exception ex) {
_log.error("Exception when trying to retrieve current token key entry", ex);
}
}
}
/**
* Helper synchronized method to overwrite the cache.
*
* @param bundle
*/
private synchronized void updateCachedTokenKeys(TokenKeysBundle bundle) {
_cachedTokenKeysBundle = bundle;
}
/**
* Returns the current token key to be used in regular auth token during signing.
*
* @return current token key id
*/
public String getCurrentTokenKeyEntry() {
return _cachedTokenKeysBundle.getCurrentKeyEntry();
}
/**
* Looks at the current key timestamp. If it is older than rotation interval, return false.
*
* @return false if key is old. True if young (younger than rotation interval)
* @throws NumberFormatException
*/
private boolean checkCurrentKeyAge() throws NumberFormatException {
long currentTokenKeyTS = Long.parseLong(_cachedTokenKeysBundle.getCurrentKeyEntry());
long now = System.currentTimeMillis();
long diff = now - currentTokenKeyTS;
if (diff >= _keyRotationIntervalInMsecs) {
return false;
}
return true;
}
/**
*
* Returns the proxy token key id, to used in proxytoken signing
*
* @return
*/
public String getProxyTokenKeyEntry() {
return _proxyTokenKeyEntry;
}
/**
* Returns the token signature key, used to sign the auth token.
*
* @return
*/
public KeyIdKeyPair getCurrentTokenSignatureKeyPair() {
String id = getCurrentTokenKeyEntry();
SecretKey key = getTokenSignatureKey(id);
return new KeyIdKeyPair(id, key);
}
/**
* Returns the proxytoken signature key. Used to sign the proxytoken.
*
* @return
*/
public KeyIdKeyPair getProxyTokenSignatureKeyPair() {
String id = getProxyTokenKeyEntry();
SecretKey key = _cachedTokenKeysBundle.getProxyKey();
return new KeyIdKeyPair(id, key);
}
/**
* Requests a signature key for the given key id.
* Due to the caches on the cluster being potentially out of sync because of key rotations,
* we look at the most current key id in our cache. If it is older than the key id we are
* requesting, we update our cache. For example, a rotation happened on node 1, a token got signed
* on node 1 with the newest key. Node 2 does not know about the rotation yet. When node 2 is presented
* with a key id that is more recent than what it knows, it reloads the cache.
*
* @param keyEntry: the key id for which we are querying
* @return
*/
public SecretKey getTokenSignatureKey(String keyEntry) {
if (keyEntry.equals(_proxyTokenKeyEntry)) {
return _cachedTokenKeysBundle.getProxyKey();
}
try {
if (CollectionUtils.isEmpty(_cachedTokenKeysBundle.getKeyEntries())) {
_log.info("Cache was empty at initialization time. Perhaps authsvc hasn't created the initial signature keys yet.");
updateCachedKeys();
} else {
// before we look in our cache, let's verify a couple things about this key
if (!checkRequestedKeyAge(keyEntry)) {
updateCachedKeys();
}
}
} catch (NumberFormatException ex) {
_log.error("Could not convert key to timestamp: {}", keyEntry);
return null;
} catch (Exception ex) {
_log.error("Could not update cached keys", ex);
}
return _cachedTokenKeysBundle.getKey(keyEntry);
}
/**
* - If a key we are being requested is really old (2 * rotation time), we should not even
* look it up in our cache. Because it could be there if our cache is old and
* we don't want to honor that key.
* - If a key is newer than our current key, we should udpate our cache
*
* @return true if the key is ok (no need to update cache), false otherwise.
*/
private boolean checkRequestedKeyAge(String keyEntry) throws NumberFormatException {
// is this key older than twice the rotation intervale ?
long requestedTokenKeyTS = Long.parseLong(keyEntry);
long now = System.currentTimeMillis();
long diff = now - requestedTokenKeyTS;
if (diff > (2 * _keyRotationIntervalInMsecs)) {
_log.debug("Requested key is older than twice the rotation intervale: {}", keyEntry);
return false;
}
// is this key newer than the most recent key in cache?
long youngestKey = Long.parseLong(_cachedTokenKeysBundle.getCurrentKeyEntry());
if (youngestKey < Long.parseLong(keyEntry)) {
_log.debug("Requested key is newer than the most recent cached key: {}", keyEntry);
return false;
}
return true;
}
/**
* Rotates keys. Creates a new key, and deletes the oldest if there are more than 2.
* Updates the cache.
* MUST BE CALLED BY A CODE OWNING INTERPROCESS LOCK
*/
public void rotateKeys() throws Exception {
try {
_log.info("Rotating keys...");
TokenKeysBundle newBundle = TokenKeysBundle.createNewTokenKeysBundleWithRotatedKeys(_cachedTokenKeysBundle);
createOrUpdateBundle(newBundle);
_log.info("Done rotating keys...");
} catch (NumberFormatException ex) {
_log.error("NumberFormatException while trying to rotate token keys, could not convert timestamp", ex);
} catch (Exception ex) {
_log.error("Exception while trying to rotate token keys", ex);
} finally {
updateCachedKeys();
}
}
// Coordinator client interraction for persistence
/**
*
* Creates or updates a TokenKeysBundle in coordinator.
*
* @param bundleIn: bundle to persist
* MUST BE CALLED BY A CODE OWNING INTERPROCESS LOCK
* @throws Exception
*/
private synchronized void createOrUpdateBundle(TokenKeysBundle bundleIn) throws Exception {
Configuration config = _coordinator.queryConfiguration(SIGNATURE_KEY_CONFIG, SIGNATURE_KEY_ID);
ConfigurationImpl configImpl = null;
if (config == null) {
configImpl = new ConfigurationImpl();
configImpl.setId(SIGNATURE_KEY_ID);
configImpl.setKind(SIGNATURE_KEY_CONFIG);
_log.debug("Creating new config");
} else {
configImpl = (ConfigurationImpl) config;
_log.debug("Updating existing config");
}
configImpl.setConfig(SIGNATURE_KEY, SerializerUtils.serializeAsBase64EncodedString(bundleIn));
_coordinator.persistServiceConfiguration(configImpl);
_log.debug("Updated keys bundle successfully");
return;
}
/**
* Reads the TokenKeysBundle from coordinator and deserializes it.
*
* @return the retrieved bundle or null if not found
* @throws Exception
*/
public TokenKeysBundle readBundle() throws Exception {
Configuration config = _coordinator.queryConfiguration(SIGNATURE_KEY_CONFIG, SIGNATURE_KEY_ID);
if (config == null || config.getConfig(SIGNATURE_KEY) == null) {
_log.warn("Token keys bundle not found");
return null;
}
String serializedBundle = config.getConfig(SIGNATURE_KEY);
_log.debug("Read bundle from coordinator: {}", serializedBundle);
return (TokenKeysBundle) SerializerUtils.deserialize(serializedBundle);
}
/**
* Generate a new SecretKey.
*
* @param algo
* @return new key
* @throws NoSuchAlgorithmException
*/
private static SecretKey generateNewKey(String algo) throws NoSuchAlgorithmException {
String encodedKey = _signatureHelper.generateKey(algo);
return _signatureHelper.createKey(encodedKey, algo);
}
/**
* Checks if our config for storing token keys exists already.
*
* @return true if exists, false otherwise.
* @throws Exception
*/
public boolean doesConfigExist() throws Exception {
List<Configuration> configs = _coordinator.queryAllConfiguration(SIGNATURE_KEY_CONFIG);
if (CollectionUtils.isEmpty(configs)) {
return false;
}
return true;
}
// --- End of Coordinator client interaction for persistence
}