org.owasp.esapi.crypto.CryptoToken Maven / Gradle / Ivy
Show all versions of esapi Show documentation
/**
* OWASP Enterprise Security API (ESAPI)
*
* This file is part of the Open Web Application Security Project (OWASP)
* Enterprise Security API (ESAPI) project. For details, please see
* http://www.owasp.org/index.php/ESAPI.
*
* Copyright © 2010 - The OWASP Foundation
*
* The ESAPI is published by OWASP under the BSD license. You should read and
* accept the LICENSE before you use, modify, and/or redistribute this software.
*
* @created 2010
*/
package org.owasp.esapi.crypto;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.owasp.esapi.ESAPI;
import org.owasp.esapi.Encryptor;
import org.owasp.esapi.Logger;
import org.owasp.esapi.errors.EncodingException;
import org.owasp.esapi.errors.EncryptionException;
import org.owasp.esapi.errors.EncryptionRuntimeException;
import org.owasp.esapi.errors.ConfigurationException;
import org.owasp.esapi.errors.ValidationException;
///// IMPORTANT NOTE: Never print / log attribute *values* as they
///// may be sensitive. Also, do not log the CryptoToken
///// itself, because even it is encrypted, the token itself
///// is often used as an authentication token.
/**
* Compute a cryptographically secure, encrypted token containing
* optional name/value pairs. The cryptographic token is computed
* like this:
*
* username;expiration_time;[<attr1>;<attr2>;...;<attrN>;]
*
* where
* username is a user account name. Defaults to <anonymous> if
* not set and it is always converted to lower case as per the rules of the
* default locale. (Note this lower case conversion is consistent with the
* default reference implementation of ESAPI's {@code User} interface.)
*
* expiration_time is time (in milliseconds) after which the encrypted
* token is considered invalid (i.e., expired). The time is stored as
* milliseconds since midnight, January 1, 1970 UTC, and optional attributes
*
* <attr1>;<attr2>;...<attrN>;
*
* are optional semicolon (';') separated name/value pairs, where each
* name/value pair has the form:
*
* name=[value] (value may be empty, but not null)
*
* The attribute value may contain any value. However, values containing
* either '=' or ';' will be quoted using '\'. Likewise, values containing '\'
* will also be quoted using '\'. Hence if original name/value pair were
* name=ab=xy\; * this would be represented as name=ab\=xy\\\;.
* To ensure things are "safe" (from a security perspective), attribute
* names must conform the the Java regular expression
*
* [A-Za-z0-9_\.-]+
*
* The attribute value on the other hand, may be any valid string. (That
* is, the value is not checked, so beware! When rendering these values, output
* encoding may be required to prevent XSS. Use ESAPI's {@code Encoder} for that.
*
* This entire semicolon-separated string is then encrypted via one of the
* {@code Encryptor.encrypt()} methods and then base64-encoded, serialized
* IV + ciphertext + MAC representation as determined by
* {@code CipherTextasPortableSerializedByteArray()} is used as the
* resulting cryptographic token.
*
* The attributes are sorted by attribute name and the attribute names
* must be unique. There are some restrictions on the attribute names.
* (See the {@link #setAttribute(String, String)} method for details.)
*
* WARNING: You should never print / log attribute values as
* they may be sensitive. Also, do not log the {@code CryptoToken}
* itself, because even though it is encrypted, the token itself is
* often used as an authentication token.
*
* @author [email protected]
* @since 2.0
*/
public class CryptoToken {
/** Represents an anonymous user. */
public static final String ANONYMOUS_USER = "";
// Default expiration time
private static final long DEFAULT_EXP_TIME = 5 * 60 * 1000; // 5 min == 300 milliseconds
private static final String DELIM = ";"; // field delimiter
private static final char DELIM_CHAR = ';'; // field delim as a char
private static final char QUOTE_CHAR = '\\'; // char used to quote delimiters, '=' and itself.
// OPEN ISSUE: Should we make these 2 regex's properties in ESAPI.properties???
private static final String ATTR_NAME_REGEX = "[A-Za-z0-9_.-]+"; // One or more alphanumeric, underscore, periods, or hyphens.
private static final String USERNAME_REGEX = "[a-z][a-z0-9_.@-]*";
private static Logger logger = ESAPI.getLogger("CryptoToken");
private String username = ANONYMOUS_USER; // Default user name if not set. Always lower case.
private long expirationTime = 0L;
// This probably needed be sorted. A HashMap would do as well.
// But this might make debugging a bit easier, so why not?
private TreeMap attributes = new TreeMap();
private transient SecretKey secretKey = null;
private Pattern attrNameRegex = Pattern.compile(ATTR_NAME_REGEX);
private Pattern userNameRegex = Pattern.compile(USERNAME_REGEX);
/**
* Create a cryptographic token using default secret key from the
* ESAPI.properties property Encryptor.MasterKey.
*/
public CryptoToken() {
secretKey = getDefaultSecretKey(
ESAPI.securityConfiguration().getEncryptionAlgorithm()
);
long now = System.currentTimeMillis();
expirationTime = now + DEFAULT_EXP_TIME;
}
// Create using specified SecretKey
/**
* Create a cryptographic token using specified {@code SecretKey}.
*
* @param skey The specified {@code SecretKey} to use to encrypt the token.
*/
public CryptoToken(SecretKey skey) {
if ( skey == null ) {
throw new IllegalArgumentException("SecretKey may not be null.");
}
secretKey = skey;
long now = System.currentTimeMillis();
expirationTime = now + DEFAULT_EXP_TIME;
}
/**
* Create using previously encrypted token encrypted with default secret
* key from ESAPI.properties.
* @param token A previously encrypted token returned by one of the
* {@code getToken()} or {@code updateToken()} methods. The
* token must have been previously encrypted using the
* using default secret key from the ESAPI.properties
* property Encryptor.MasterKey.
* @throws EncryptionException Thrown if they are any problems while decrypting
* the token using the default secret key from
* ESAPI.properties or if the decrypted
* token is not properly formatted.
*/
public CryptoToken(String token) throws EncryptionException {
secretKey = getDefaultSecretKey(
ESAPI.securityConfiguration().getEncryptionAlgorithm()
);
try {
decryptToken(secretKey, token);
} catch (EncodingException e) {
throw new EncryptionException("Decryption of token failed. Token improperly encoded or encrypted with different key.",
"Can't decrypt token because not correctly encoded or encrypted with different key.", e);
}
if ( username == null ) {
throw new IllegalArgumentException("Programming error or malformed token: Decrypted token found username null.");
}
if ( expirationTime <= 0 ) {
throw new IllegalArgumentException("Programming error or malformed token: Decrypted token found expirationTime <= 0.");
}
}
/**
* Create cryptographic token using previously encrypted token that was
* encrypted with specified secret key.
*
* @param token A previously encrypted token returned by one of the
* {@code getToken()} or {@code updateToken()} methods.
* @throws EncryptionException Thrown if they are any problems while decrypting
* the token using the default secret key from
* ESAPI.properties or if the decrypted
* token is not properly formatted.
*/
// token is a previously encrypted token (i.e., CryptoToken.getToken())
// with different SecretKey other than the one in ESAPI.properties
public CryptoToken(SecretKey skey, String token) throws EncryptionException {
if ( skey == null ) {
throw new IllegalArgumentException("SecretKey may not be null.");
}
if ( token == null ) {
throw new IllegalArgumentException("Token may not be null");
}
secretKey = skey;
try {
decryptToken(secretKey, token);
} catch (EncodingException e) {
throw new EncryptionException("Decryption of token failed. Token improperly encoded.",
"Can't decrypt token because not correctly encoded.", e);
}
if ( username == null ) {
String exm = "Programming error???: Decrypted token found username null.";
throw new EncryptionException(exm, exm);
}
if ( expirationTime <= 0 ) {
String exm = "Programming error???: Decrypted token found expirationTime <= 0.";
throw new EncryptionException(exm, exm);
}
}
/**
* Retrieve the user account name associated with this {@code CryptoToken}
* object.
* @return The user account name. The string represented by
* {@link #ANONYMOUS_USER} is returned if
* {@link #setUserAccountName(String)} was never called.
*/
public String getUserAccountName() {
return ( (username != null) ? username : ANONYMOUS_USER );
}
/**
* Set the user account name associated with this cryptographic token
* object. The user account name is converted to lower case.
* @param userAccountName The user account name.
* @throws ValidationException Thrown if user account name is not valid, i.e.,
* if it doesn't conform to the regular expression
* given by "[a-z][a-z0-9_.@-]*". (Note that the
* parameter {@code userAccountName} is first converted
* to lower case before checked against the regular
* expression.)
*/
public void setUserAccountName(String userAccountName) throws ValidationException {
if ( userAccountName == null ) {
throw new IllegalArgumentException("User account name may not be null.");
}
// Converting to lower case first allows a simpler regex.
String userAcct = userAccountName.toLowerCase();
// Check to make sure that attribute name is valid as per our regex.
Matcher userNameChecker = userNameRegex.matcher(userAcct);
if ( userNameChecker.matches() ) {
username = userAcct;
} else {
throw new ValidationException("Invalid user account name encountered.",
"User account name " + userAccountName +
" does not match regex " +
USERNAME_REGEX + " after conversion to lowercase.");
}
}
/** Check if token has expired yet.
* @return True if token has expired; false otherwise.
*/
public boolean isExpired() {
return System.currentTimeMillis() > expirationTime;
}
/**
* Set expiration time to expire in 'interval' seconds (NOT milliseconds).
* @param intervalSecs Number of seconds in the future from current date/time
* to set expiration. Must be positive.
*/
public void setExpiration(int intervalSecs) throws IllegalArgumentException
{
int intervalMillis = intervalSecs * 1000; // Need to convert secs to millisec.
// Don't want to use assertion here, because if they are disabled,
// this would result in setting the expiration time prior to the
// current time, hence it would already be expired.
if ( intervalMillis <= 0) {
throw new IllegalArgumentException("intervalSecs argument, converted to millisecs, must be > 0.");
}
// Check for arithmetic overflow here. In reality, this condition
// should never happen, but we want to avoid it--even theoretically--
// since otherwise, it could have security implications.
long now = System.currentTimeMillis();
preAdd(now, intervalMillis);
expirationTime = now + intervalMillis;
}
/**
* Set expiration time for a specific date/time.
* @param expirationDate The date/time at which the token will fail. Must
* be after the current date/time.
* @throws IllegalArgumentException Thrown if the parameter is null.
*/
public void setExpiration(Date expirationDate) throws IllegalArgumentException
{
if ( expirationDate == null ) {
throw new IllegalArgumentException("expirationDate may not be null.");
}
long curTime = System.currentTimeMillis();
long expTime = expirationDate.getTime();
if ( expTime <= curTime ) {
throw new IllegalArgumentException("Expiration date must be after current date/time.");
}
expirationTime = expTime;
}
/**
* Return the expiration time in milliseconds since epoch time (midnight,
* January 1, 1970 UTC).
* @return The current expiration time.
*/
public long getExpiration() {
// Assertion here is safe as it is just a sanity check and this check is
// make elsewhere. Plus this is a getter, not a setting, so if it's
// messed up, it happened somewhere else.
assert expirationTime > 0L : "Programming error: Expiration time <= 0";
return expirationTime;
}
/**
* Return the expiration time as a {@code Date}.
* @return The {@code Date} object representing the expiration time.
*/
public Date getExpirationDate() {
return new Date( getExpiration() );
}
/**
* Set a name/value pair as an attribute.
* @param name The attribute name
* @param value The attribute value
* @throws ValidationException Thrown if the attribute name is not properly
* formed. That is, the attribute name does not
* match the regular expression "[A-Za-z0-9_.-]+".
*/
public void setAttribute(String name, String value) throws ValidationException {
if ( name == null || name.length() == 0 ) {
throw new ValidationException("Null or empty attribute NAME encountered",
"Attribute NAMES may not be null or empty string.");
}
if ( value == null ) {
throw new ValidationException("Null attribute VALUE encountered for attr name " + name,
"Attribute VALUE may not be null; attr name: " + name);
}
// NOTE: OTOH, it *is* VALID if the _value_ is empty! Null values cause too much trouble
// to make it worth the effort of getting it to work consistently.
// Check to make sure that attribute name is valid as per our regex.
Matcher attrNameChecker = attrNameRegex.matcher(name);
if ( attrNameChecker.matches() ) {
attributes.put(name, value);
} else {
throw new ValidationException("Invalid attribute name encountered.",
"Attribute name " + name + " does not match regex " +
ATTR_NAME_REGEX);
}
}
/**
* Add the specified collection of attributes to the current attributes.
* If there are duplicate attributes specified, they will replace any
* existing ones.
*
* @param attrs Name/value pairs of attributes to add or replace the existing
* attributes. Map must be non-null, but may be empty.
* @throws ValidationException Thrown if one of the keys in the specified
* parameter {@code attrs} is not a valid name.
* That is, all attribute names must match the regular
* expression ""[A-Za-z0-9_.-]+".
* @see #setAttribute(String, String)
*/
public void addAttributes(final Map attrs) throws ValidationException {
if ( attrs == null ) {
throw new IllegalArgumentException("Attribute map may not be null.");
}
Set< Entry > keyValueSet = attrs.entrySet();
Iterator> it = keyValueSet.iterator();
while( it.hasNext() ) {
Map.Entry entry = it.next();
String key = entry.getKey();
String value = entry.getValue();
setAttribute(key, value);
}
return;
}
/**
* Retrieve the attribute with the specified name.
* @param name The attribute name.
* @return The value associated with the attribute name. If attribute is not
* set, then {@code null} is returned.
*/
public String getAttribute(String name) {
return attributes.get(name);
}
/**
* Retrieve a {@code Map} that is a clone of all the attributes. A copy
* is returned so that the attributes in {@code CrytpToken} are unaffected
* by alterations made the returned {@code Map}. (Otherwise, multi-threaded code
* could get trick.
*
* @return A {@code Map} of all the attributes.
* @see #getAttribute(String)
*/
@SuppressWarnings("unchecked")
public Map getAttributes() {
// Unfortunately, this requires a cast, which requires us to supress warnings.
return (Map) attributes.clone();
}
/**
* Removes all the attributes (if any) associated with this token. Note
* that this does not clear / reset the user account name or expiration time.
*/
public void clearAttributes() {
attributes.clear();
}
/**
* Return the new encrypted token as a base64-encoded string, encrypted with
* the specified {@code SecretKey} which may be a different key than what the
* token was originally encrypted with. E.g.,
*
* Alice:
* SecretKey aliceSecretKey = ...; // Shared with Bob
* CryptoToken cryptoToken = new CryptoToken(skey1);
* cryptoToken.setUserAccountName("kwwall");
* cryptoToken.setAttribute("role", "admin");
* cryptoToken.setAttribute("state", "Ohio");
* String token = cryptoToken.getToken(); // Encrypted with skey1
* // send token to Bob ...
* --------------------------------------------------------------------
* Bob:
* ...
* SecretKey aliceSecretKey = ... // Shared with Alice
* SecretKey bobSecretKey = ...; // Shared with Carol
* CryptoToken cryptoToken = new CryptoToken(aliceSecretKey, tokenFromAlice);
*
* // Re-encrypt for Carol using my (Bob's) key...
* String tokenForCarol = cryptoToken.getToken(bobSecretKey);
* // send tokenForCarol to Carol ...
* // use token ourselves
* --------------------------------------------------------------------
* Carol:
* ...
* SecretKey bobSecretKey = ...; // Shared with Bob.
* CryptoToken cryptoToken = new CryptoToken(bobSecretKey, tokenFromBob);
* if ( ! cryptoToken.isExpired() ) {
* String userName = cryptoToken.getUserAccountName();
* String roleName = cryptoToken.getAttribute("role");
* if ( roleName != null && roleName.equalsIgnoreCase("admin") ) {
* // grant admin access...
* ...
* }
* }
* ...
*
* @param skey The specified key to (re)encrypt the token.
* @return The newly encrypted token.
*/
public String getToken(SecretKey skey) throws EncryptionException {
return createEncryptedToken(skey);
}
/**
* Update the (current) expiration time by adding the specified number of
* seconds to it and then re-encrypting with the current {@code SecretKey}
* that was used to construct this object.
*
* @param additionalSecs The additional number of seconds to add to the
* current expiration time. This number must be
* >= 0 or otherwise an {@code IllegalArgumentException}
* is thrown.
* @return The re-encrypted token with the updated expiration time is returned.
* @throws IllegalArgumentException Thrown if parameter {@code additionalSecs}
* is less than 0.
* @throws EncryptionException Thrown if the encryption fails.
* @throws ValidationException Thrown if the token will have already expired
* even after adding the specified number of
* additional seconds.
* @throws ArithmeticException If additional seconds is large enough such
* that it would cause an arithmetic overflow
* with a long (the current expiration time)
* when added to the {@code additionalSecs}
* parameter.
*/
public String updateToken(int additionalSecs) throws EncryptionException, ValidationException {
if ( additionalSecs < 0) {
throw new IllegalArgumentException("additionalSecs argument must be >= 0.");
}
// Avoid integer overflow. This could happen if one first calls
// setExpiration(Date) with a date far into the future. We want
// to avoid overflows as they might lead to security vulnerabilities.
long curExpTime = getExpiration();
preAdd(curExpTime, additionalSecs * 1000);
// Note: Can't use setExpiration(int) here was this needs a
// 'long'. Could convert to Date first, and use
// setExpiration(Date) but that hardly seems worth the trouble.
expirationTime = curExpTime + (additionalSecs * 1000);
if ( isExpired() ) {
// Too bad there is no ProcrastinationException ;-)
expirationTime = curExpTime; // Restore the original value (which still may
// be expired.
throw new ValidationException("Token timed out.",
"Cryptographic token not increased to sufficient value to prevent timeout.");
}
// Don't change anything else (user acct name, attributes, skey, etc.)
return getToken();
}
/**
* Return the new encrypted token as a base64-encoded string, encrypted with
* the specified {@code SecretKey} with which this object was constructed.
*
* @return The newly encrypted token.
* @see #getToken(SecretKey)
*/
public String getToken() throws EncryptionException {
return createEncryptedToken(secretKey);
}
// Create the actual encrypted token based on the specified SecretKey.
// This method will ensure that the decrypted token always ends with an
// unquoted delimiter.
private String createEncryptedToken(SecretKey skey) throws EncryptionException {
StringBuilder sb = new StringBuilder( getUserAccountName() + DELIM);
// CHECKME: Should we check here to see if token has already expired
// and refuse to encrypt it (by throwing exception) if it has???
// If so, then updateToken() should also be revisited.
sb.append( getExpiration() ).append( DELIM );
sb.append( getQuotedAttributes() );
Encryptor encryptor = ESAPI.encryptor();
CipherText ct = encryptor.encrypt(skey, new PlainText( sb.toString() ) );
String b64 =
ESAPI.encoder().encodeForBase64(ct.asPortableSerializedByteArray(),
false);
return b64;
}
// Return a string of all the attributes, properly quoted. This is used in
// creating the encrypted token. Note that this method ensures that the
// quoted attribute string always ends with an (quoted) delimiter.
private String getQuotedAttributes() {
StringBuilder sb = new StringBuilder();
Set< Entry > keyValueSet = attributes.entrySet();
Iterator> it = keyValueSet.iterator();
while( it.hasNext() ) {
Map.Entry entry = it.next();
String key = entry.getKey();
String value = entry.getValue();
// Because attribute values may be confidential, we don't want to log them!
logger.debug(Logger.EVENT_UNSPECIFIED, " " + key + " -> ");
sb.append(key + "=" + quoteAttributeValue( value ) + DELIM);
}
return sb.toString();
}
// Do NOT define a toString() method as there may be sensitive
// information contained in the attribute names. If we absolutely
// need this, then only return the username and expiration time, and
// _maybe_ the attribute names, but not there values. And obviously,
// we NEVER want to include the SecretKey should we decide to do this.
/*
* public String toString() { return null; }
*/
// Quote any special characters in value.
private static String quoteAttributeValue(String value) {
if ( value == null ) {
String exm = "Programming error???: Value should not be null."; // Empty string is OK.
throw new EncryptionRuntimeException(exm, exm);
}
StringBuilder sb = new StringBuilder();
char[] charArray = value.toCharArray();
for( int i = 0; i < charArray.length; i++ ) {
char c = charArray[i];
if ( c == QUOTE_CHAR || c == '=' || c == DELIM_CHAR ) {
sb.append(QUOTE_CHAR).append(c);
} else {
sb.append(c);
}
}
return sb.toString();
}
// Parse the possibly quoted value and return the unquoted value.
private static String parseQuotedValue(String quotedValue) {
StringBuilder sb = new StringBuilder();
char[] charArray = quotedValue.toCharArray();
for( int i = 0; i < charArray.length; i++ ) {
char c = charArray[i];
if ( c == QUOTE_CHAR ) {
i++; // Skip past quote character.
sb.append( charArray[i] );
} else {
sb.append(c);
}
}
return sb.toString();
}
/*
* Decrypt the encrypted token and parse it into the individual components.
* The string should always end with a semicolon (;) even when there are
* no attributes set.
*
* Example of how quoted string might look:
*
* v v v v v
* kwwall;1291183520293;abc=x\=yx;xyz=;efg=a\;a\;;bbb=quotes\\tuff\;;
| | | | | |
*
*
*/
private void decryptToken(SecretKey skey, String b64token) throws EncryptionException, EncodingException {
byte[] token = null;
try {
token = ESAPI.encoder().decodeFromBase64(b64token);
} catch (IOException e) {
// Ideally, we'd like to be able to include the actual (munged) token itself.
// It's encrypted, but could be arbitrarily long, especially since it is not valid
// encoding. In theory, it may help debugging as sometimes it may be a simple
// case like someone failing to apply some other type of encoding
// consistently (e.g., URL encoding), in which case logging this should
// make this pretty obvious once a few of these are logged.
//
// OTOH, since tokens may be used as authentication tokens (see
// "WARNING" in the class Javadoc) some insider could intentionally botch
// a token (e.g., just remove the base64 padding characters) just to
// get an otherwise valid token logged somewhere they can access it.
// Therefore, I have decided NOT to include in in the logMessage
// part of the exception.
//
// Note that prior to ESAPI 2.2.0.0, the token _was_ logged, but has
// now been corrected.
//
throw new EncodingException("Invalid base64 encoding.",
"Invalid base64 encoding for token [REDACTED]");
}
CipherText ct = CipherText.fromPortableSerializedBytes(token);
Encryptor encryptor = ESAPI.encryptor();
PlainText pt = encryptor.decrypt(skey, ct);
String str = pt.toString();
if ( ! str.endsWith(DELIM) ) {
String exm = "Programming error???: Expecting decrypted token to end with delim char, " + DELIM_CHAR;
throw new EncryptionException(exm, exm);
}
char[] charArray = str.toCharArray();
int prevPos = -1; // Position of previous unquoted delimiter.
int fieldNo = 0;
ArrayList