From 7a2e1e13d371fcc77e992a5ad83bb44a5b6ed467 Mon Sep 17 00:00:00 2001 From: Ben Alex Date: Fri, 2 May 2008 10:38:56 +0000 Subject: [PATCH] SEC-811: Provide a mechanism to allocate and rebuild cryptographically strong, randomised tokens. --- .../security/token/DefaultToken.java | 59 ++++++ .../KeyBasedPersistenceTokenService.java | 170 ++++++++++++++++++ .../token/SecureRandomFactoryBean.java | 69 +++++++ .../springframework/security/token/Token.java | 45 +++++ .../security/token/TokenService.java | 46 +++++ .../security/token/DefaultTokenTests.java | 43 +++++ .../KeyBasedPersistenceTokenServiceTests.java | 84 +++++++++ .../token/SecureRandomFactoryBeanTests.java | 51 ++++++ .../security/util/Sha512DigestUtils.java | 87 +++++++++ 9 files changed, 654 insertions(+) create mode 100644 core/src/main/java/org/springframework/security/token/DefaultToken.java create mode 100644 core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java create mode 100644 core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java create mode 100644 core/src/main/java/org/springframework/security/token/Token.java create mode 100644 core/src/main/java/org/springframework/security/token/TokenService.java create mode 100644 core/src/test/java/org/springframework/security/token/DefaultTokenTests.java create mode 100644 core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java create mode 100644 core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java create mode 100644 core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java diff --git a/core/src/main/java/org/springframework/security/token/DefaultToken.java b/core/src/main/java/org/springframework/security/token/DefaultToken.java new file mode 100644 index 0000000000..b6ecf96672 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/DefaultToken.java @@ -0,0 +1,59 @@ +package org.springframework.security.token; + +import java.util.Date; + +import org.springframework.util.Assert; + +/** + * The default implementation of {@link Token}. + * + * @author Ben Alex + * @since 2.0.1 + */ +public class DefaultToken implements Token { + private String key; + private long keyCreationTime; + private String extendedInformation; + + public DefaultToken(String key, long keyCreationTime, String extendedInformation) { + Assert.hasText(key, "Key required"); + Assert.notNull(extendedInformation, "Extended information cannot be null"); + this.key = key; + this.keyCreationTime = keyCreationTime; + this.extendedInformation = extendedInformation; + } + + public String getKey() { + return key; + } + + public long getKeyCreationTime() { + return keyCreationTime; + } + + public String getExtendedInformation() { + return extendedInformation; + } + + public boolean equals(Object obj) { + if (obj != null && obj instanceof DefaultToken) { + DefaultToken rhs = (DefaultToken) obj; + return this.key.equals(rhs.key) && this.keyCreationTime == rhs.keyCreationTime && this.extendedInformation.equals(rhs.extendedInformation); + } + return false; + } + + public int hashCode() { + int code = 979; + code = code * key.hashCode(); + code = code * new Long(keyCreationTime).hashCode(); + code = code * extendedInformation.hashCode(); + return code; + } + + public String toString() { + return "DefaultToken[key=" + new String(key) + "; creation=" + new Date(keyCreationTime) + "; extended=" + extendedInformation + "]"; + } + + +} diff --git a/core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java b/core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java new file mode 100644 index 0000000000..07bff76589 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java @@ -0,0 +1,170 @@ +package org.springframework.security.token; + +import java.io.UnsupportedEncodingException; +import java.security.SecureRandom; +import java.util.Date; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.binary.Hex; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.security.util.Sha512DigestUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Basic implementation of {@link TokenService} that is compatible with clusters and across machine restarts, + * without requiring database persistence. + * + *

+ * Keys are produced in the format: + *

+ * + *

+ * Base64(creationTime + ":" + hex(pseudoRandomNumber) + ":" + extendedInformation + ":" + + * Sha512Hex(creationTime + ":" + hex(pseudoRandomNumber) + ":" + extendedInformation + ":" + serverSecret) ) + *

+ * + *

+ * In the above, creationTime, tokenKey and extendedInformation + * are equal to that stored in {@link Token}. The Sha512Hex includes the same payload, + * plus a serverSecret. + *

+ * + *

+ * The serverSecret varies every millisecond. It relies on two static server-side secrets. The first + * is a password, and the second is a server integer. Both of these must remain the same for any issued keys + * to subsequently be recognised. The applicable serverSecret in any millisecond is computed by + * password + ":" + (creationTime % serverInteger). This approach + * further obfuscates the actual server secret and renders attempts to compute the server secret more + * limited in usefulness (as any false tokens would be forced to have a creationTime equal + * to the computed hash). Recall that framework features depending on token services should reject tokens + * that are relatively old in any event. + *

+ * + *

+ * A further consideration of this class is the requirement for cryptographically strong pseudo-random numbers. + * To this end, the use of {@link SecureRandomFactoryBean} is recommended to inject the property. + *

+ * + *

+ * This implementation uses UTF-8 encoding internally for string manipulation. + *

+ * + * @author Ben Alex + * + */ +public class KeyBasedPersistenceTokenService implements TokenService, InitializingBean { + private int pseudoRandomNumberBits = 256; + private String serverSecret; + private Integer serverInteger; + private SecureRandom secureRandom; + + public Token allocateToken(String extendedInformation) { + Assert.notNull(extendedInformation, "Must provided non-null extendedInformation (but it can be empty)"); + long creationTime = new Date().getTime(); + String serverSecret = computeServerSecretApplicableAt(creationTime); + String pseudoRandomNumber = generatePseudoRandomNumber(); + String content = new Long(creationTime).toString() + ":" + pseudoRandomNumber + ":" + extendedInformation; + + // Compute key + String sha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret); + String keyPayload = content + ":" + sha512Hex; + String key = convertToString(Base64.encodeBase64(convertToBytes(keyPayload))); + + return new DefaultToken(key, creationTime, extendedInformation); + } + + public Token verifyToken(String key) { + if (key == null || "".equals(key)) { + return null; + } + String[] tokens = StringUtils.delimitedListToStringArray(convertToString(Base64.decodeBase64(convertToBytes(key))), ":"); + Assert.isTrue(tokens.length >= 4, "Expected 4 or more tokens but found " + tokens.length); + + long creationTime; + try { + creationTime = Long.decode(tokens[0]).longValue(); + } catch (NumberFormatException nfe) { + throw new IllegalArgumentException("Expected number but found " + tokens[0]); + } + + String serverSecret = computeServerSecretApplicableAt(creationTime); + String pseudoRandomNumber = tokens[1]; + + // Permit extendedInfo to itself contain ":" characters + StringBuffer extendedInfo = new StringBuffer(); + for (int i = 2; i < tokens.length-1; i++) { + if (i > 2) { + extendedInfo.append(":"); + } + extendedInfo.append(tokens[i]); + } + + String sha1Hex = tokens[tokens.length-1]; + + // Verification + String content = new Long(creationTime).toString() + ":" + pseudoRandomNumber + ":" + extendedInfo.toString(); + String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret); + Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure"); + + return new DefaultToken(key, creationTime, extendedInfo.toString()); + } + + private byte[] convertToBytes(String input) { + try { + return input.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + private String convertToString(byte[] bytes) { + try { + return new String(bytes, "UTF-8"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @return a pseduo random number (hex encoded) + */ + private String generatePseudoRandomNumber() { + byte[] randomizedBits = new byte[pseudoRandomNumberBits]; + secureRandom.nextBytes(randomizedBits); + return new String(Hex.encodeHex(randomizedBits)); + } + + private String computeServerSecretApplicableAt(long time) { + return serverSecret + ":" + new Long(time % serverInteger.intValue()).intValue(); + } + + /** + * @param serverSecret the new secret, which can contain a ":" if desired (never being sent to the client) + */ + public void setServerSecret(String serverSecret) { + this.serverSecret = serverSecret; + } + + public void setSecureRandom(SecureRandom secureRandom) { + this.secureRandom = secureRandom; + } + + /** + * @param pseudoRandomNumberBits changes the number of bits issued (must be >= 0; defaults to 256) + */ + public void setPseudoRandomNumberBits(int pseudoRandomNumberBits) { + Assert.isTrue(pseudoRandomNumberBits >= 0, "Must have a positive pseudo random number bit size"); + this.pseudoRandomNumberBits = pseudoRandomNumberBits; + } + + public void setServerInteger(Integer serverInteger) { + this.serverInteger = serverInteger; + } + + public void afterPropertiesSet() throws Exception { + Assert.hasText(serverSecret, "Server secret required"); + Assert.notNull(serverInteger, "Server integer required"); + Assert.notNull(secureRandom, "SecureRandom instance required"); + } +} diff --git a/core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java b/core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java new file mode 100644 index 0000000000..a7bf036832 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java @@ -0,0 +1,69 @@ +package org.springframework.security.token; + +import java.io.InputStream; +import java.security.SecureRandom; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; + +/** + * Creates a {@link SecureRandom} instance. + * + * @author Ben Alex + * @since 2.0.1 + * + */ +public class SecureRandomFactoryBean implements FactoryBean { + + private String algorithm = "SHA1PRNG"; + private Resource seed; + + public Object getObject() throws Exception { + SecureRandom rnd = SecureRandom.getInstance(algorithm); + + if (seed != null) { + // Seed specified, so use it + byte[] seedBytes = FileCopyUtils.copyToByteArray(seed.getInputStream()); + rnd.setSeed(seedBytes); + } else { + // Request the next bytes, thus eagerly incurring the expense of default seeding + rnd.nextBytes(new byte[1]); + } + + return rnd; + } + + public Class getObjectType() { + return SecureRandom.class; + } + + public boolean isSingleton() { + return false; + } + + /** + * Allows the Pseudo Random Number Generator (PRNG) algorithm to be nominated. Defaults to + * SHA1PRNG. + * + * @param algorithm to use (mandatory) + */ + public void setAlgorithm(String algorithm) { + Assert.hasText(algorithm, "Algorithm required"); + this.algorithm = algorithm; + } + + /** + * Allows the user to specify a resource which will act as a seed for the {@link SecureRandom} + * instance. Specifically, the resource will be read into an {@link InputStream} and those + * bytes presented to the {@link SecureRandom#setSeed(byte[])} method. Note that this will + * simply supplement, rather than replace, the existing seed. As such, it is always safe to + * set a seed using this method (it never reduces randomness). + * + * @param seed to use, or null if no additional seeding is needed + */ + public void setSeed(Resource seed) { + this.seed = seed; + } +} diff --git a/core/src/main/java/org/springframework/security/token/Token.java b/core/src/main/java/org/springframework/security/token/Token.java new file mode 100644 index 0000000000..1b09c8960e --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/Token.java @@ -0,0 +1,45 @@ +package org.springframework.security.token; + + +/** + * A token issued by {@link TokenService}. + * + *

+ * It is important that the keys assigned to tokens are sufficiently randomised and secured that + * they can serve as identifying a unique user session. Implementations of {@link TokenService} + * are free to use encryption or encoding strategies of their choice. It is strongly recommended that + * keys are of sufficient length to balance safety against persistence cost. In relation to persistence + * cost, it is strongly recommended that returned keys are small enough for encoding in a cookie. + *

+ * + * @author Ben Alex + * @since 2.0.1 + */ +public interface Token { + + /** + * Obtains the randomised, secure key assigned to this token. Presentation of this token to + * {@link TokenService} will always return a Token that is equal to the original + * Token issued for that key. + * + * @return a key with appropriate randomness and security. + */ + String getKey(); + + /** + * The time the token key was initially created is available from this method. Note that a given + * token must never have this creation time changed. If necessary, a new token can be + * requested from the {@link TokenService} to replace the original token. + * + * @return the time this token key was created, in the same format as specified by {@link Date#getTime()). + */ + long getKeyCreationTime(); + + /** + * Obtains the extended information associated within the token, which was presented when the token + * was first created. + * + * @return the user-specified extended information, if any + */ + String getExtendedInformation(); +} diff --git a/core/src/main/java/org/springframework/security/token/TokenService.java b/core/src/main/java/org/springframework/security/token/TokenService.java new file mode 100644 index 0000000000..f193b5b7b8 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/TokenService.java @@ -0,0 +1,46 @@ +package org.springframework.security.token; + + +/** + * Provides a mechanism to allocate and rebuild secure, randomised tokens. + * + *

+ * Implementations are solely concern with issuing a new {@link Token} on demand. The + * issued Token may contain user-specified extended information. The token also + * contains a cryptographically strong, byte array-based key. This permits the token to be + * used to identify a user session, if desired. The key can subsequently be re-presented + * to the TokenService for verification and reconstruction of a Token + * equal to the original Token. + *

+ * + *

+ * Given the tightly-focused behaviour provided by this interface, it can serve as a building block + * for more sophisticated token-based solutions. For example, authentication systems that depend on + * stateless session keys. These could, for instance, place the username inside the user-specified + * extended information associated with the key). It is important to recognise that we do not intend + * for this interface to be expanded to provide such capabilities directly. + *

+ * + * @author Ben Alex + * @since 2.0.1 + * + */ +public interface TokenService { + /** + * Forces the allocation of a new {@link Token}. + * + * @param the extended information desired in the token (cannot be null, but can be empty) + * @return a new token that has not been issued previously, and is guaranteed to be recognised + * by this implementation's {@link #verifyToken(String)} at any future time. + */ + Token allocateToken(String extendedInformation); + + /** + * Permits verification the <{@link Token#getKey()} was issued by this TokenService and + * reconstructs the corresponding Token. + * + * @param key as obtained from {@link Token#getKey()} and created by this implementation + * @return the token, or null if the token was not issued by this TokenService + */ + Token verifyToken(String key); +} diff --git a/core/src/test/java/org/springframework/security/token/DefaultTokenTests.java b/core/src/test/java/org/springframework/security/token/DefaultTokenTests.java new file mode 100644 index 0000000000..a7254e7cab --- /dev/null +++ b/core/src/test/java/org/springframework/security/token/DefaultTokenTests.java @@ -0,0 +1,43 @@ +package org.springframework.security.token; + +import java.util.Date; + +import junit.framework.Assert; + +import org.junit.Test; + +/** + * Tests {@link DefaultToken}. + * + * @author Ben Alex + * + */ +public class DefaultTokenTests { + @Test + public void testEquality() { + String key = "key"; + long created = new Date().getTime(); + String extendedInformation = "extended"; + + DefaultToken t1 = new DefaultToken(key, created, extendedInformation); + DefaultToken t2 = new DefaultToken(key, created, extendedInformation); + Assert.assertEquals(t1, t2); + } + + @Test(expected=IllegalArgumentException.class) + public void testRejectsNullExtendedInformation() { + String key = "key"; + long created = new Date().getTime(); + new DefaultToken(key, created, null); + } + + @Test + public void testEqualityWithDifferentExtendedInformation3() { + String key = "key"; + long created = new Date().getTime(); + + DefaultToken t1 = new DefaultToken(key, created, "length1"); + DefaultToken t2 = new DefaultToken(key, created, "longerLength2"); + Assert.assertFalse(t1.equals(t2)); + } +} diff --git a/core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java b/core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java new file mode 100644 index 0000000000..ab0895f35d --- /dev/null +++ b/core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java @@ -0,0 +1,84 @@ + + +package org.springframework.security.token; + +import java.security.SecureRandom; +import java.util.Date; + +import junit.framework.Assert; + +import org.junit.Test; + +/** + * Tests {@link KeyBasedPersistenceTokenService}. + * + * @author Ben Alex + * + */ +public class KeyBasedPersistenceTokenServiceTests { + + private KeyBasedPersistenceTokenService getService() { + SecureRandomFactoryBean fb = new SecureRandomFactoryBean(); + KeyBasedPersistenceTokenService service = new KeyBasedPersistenceTokenService(); + service.setServerSecret("MY:SECRET$$$#"); + service.setServerInteger(new Integer(454545)); + try { + SecureRandom rnd = (SecureRandom) fb.getObject(); + service.setSecureRandom(rnd); + service.afterPropertiesSet(); + } catch (Exception e) { + throw new RuntimeException(e); + } + return service; + } + + @Test + public void testOperationWithSimpleExtendedInformation() { + KeyBasedPersistenceTokenService service = getService(); + Token token = service.allocateToken("Hello world"); + Token result = service.verifyToken(token.getKey()); + Assert.assertEquals(token, result); + } + + + @Test + public void testOperationWithComplexExtendedInformation() { + KeyBasedPersistenceTokenService service = getService(); + Token token = service.allocateToken("Hello:world:::"); + Token result = service.verifyToken(token.getKey()); + Assert.assertEquals(token, result); + } + + @Test + public void testOperationWithEmptyRandomNumber() { + KeyBasedPersistenceTokenService service = getService(); + service.setPseudoRandomNumberBits(0); + Token token = service.allocateToken("Hello:world:::"); + Token result = service.verifyToken(token.getKey()); + Assert.assertEquals(token, result); + } + + @Test + public void testOperationWithNoExtendedInformation() { + KeyBasedPersistenceTokenService service = getService(); + Token token = service.allocateToken(""); + Token result = service.verifyToken(token.getKey()); + Assert.assertEquals(token, result); + } + + @Test(expected=IllegalArgumentException.class) + public void testOperationWithMissingKey() { + KeyBasedPersistenceTokenService service = getService(); + Token token = new DefaultToken("", new Date().getTime(), ""); + service.verifyToken(token.getKey()); + } + + @Test(expected=IllegalArgumentException.class) + public void testOperationWithTamperedKey() { + KeyBasedPersistenceTokenService service = getService(); + Token goodToken = service.allocateToken(""); + String fake = goodToken.getKey().toUpperCase(); + Token token = new DefaultToken(fake, new Date().getTime(), ""); + service.verifyToken(token.getKey()); + } +} diff --git a/core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java b/core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java new file mode 100644 index 0000000000..0d6dbdb3b0 --- /dev/null +++ b/core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java @@ -0,0 +1,51 @@ +package org.springframework.security.token; + +import java.security.SecureRandom; + +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +import junit.framework.Assert; + +/** + * Tests {@link SecureRandomFactoryBean}. + * + * @author Ben Alex + * + */ +public class SecureRandomFactoryBeanTests { + @Test + public void testObjectType() { + SecureRandomFactoryBean factory = new SecureRandomFactoryBean(); + Assert.assertEquals(SecureRandom.class, factory.getObjectType()); + } + + @Test + public void testIsSingleton() { + SecureRandomFactoryBean factory = new SecureRandomFactoryBean(); + Assert.assertFalse(factory.isSingleton()); + } + + @Test + public void testCreatesUsingDefaults() throws Exception { + SecureRandomFactoryBean factory = new SecureRandomFactoryBean(); + Object result = factory.getObject(); + Assert.assertTrue(result instanceof SecureRandom); + int rnd = ((SecureRandom)result).nextInt(); + Assert.assertTrue(rnd != 0); + } + + @Test + public void testCreatesUsingSeed() throws Exception { + SecureRandomFactoryBean factory = new SecureRandomFactoryBean(); + Resource resource = new ClassPathResource("org/springframework/security/token/SecureRandomFactoryBeanTests.class"); + Assert.assertNotNull(resource); + factory.setSeed(resource); + Object result = factory.getObject(); + Assert.assertTrue(result instanceof SecureRandom); + int rnd = ((SecureRandom)result).nextInt(); + Assert.assertTrue(rnd != 0); + } + +} diff --git a/core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java b/core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java new file mode 100644 index 0000000000..2ba2a89fac --- /dev/null +++ b/core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java @@ -0,0 +1,87 @@ +package org.springframework.security.util; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.apache.commons.codec.binary.Hex; + +/** + * Provides SHA512 digest methods. + * + *

+ * Based on Commons Codec, which does not presently provide SHA512 support. + *

+ * + * @author Ben Alex + * @since 2.0.1 + * + */ +public abstract class Sha512DigestUtils { + /** + * Returns a MessageDigest for the given algorithm. + * + * @param algorithm The MessageDigest algorithm name. + * @return An MD5 digest instance. + * @throws RuntimeException when a {@link java.security.NoSuchAlgorithmException} is caught, + */ + static MessageDigest getDigest(String algorithm) { + try { + return MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e.getMessage()); + } + } + + /** + * Returns an SHA digest. + * + * @return An SHA digest instance. + * @throws RuntimeException when a {@link java.security.NoSuchAlgorithmException} is caught, + */ + private static MessageDigest getSha512Digest() { + return getDigest("SHA-512"); + } + + /** + * Calculates the SHA digest and returns the value as a + * byte[]. + * + * @param data Data to digest + * @return SHA digest + */ + public static byte[] sha(byte[] data) { + return getSha512Digest().digest(data); + } + + /** + * Calculates the SHA digest and returns the value as a + * byte[]. + * + * @param data Data to digest + * @return SHA digest + */ + public static byte[] sha(String data) { + return sha(data.getBytes()); + } + + /** + * Calculates the SHA digest and returns the value as a hex string. + * + * @param data Data to digest + * @return SHA digest as a hex string + */ + public static String shaHex(byte[] data) { + return new String(Hex.encodeHex(sha(data))); + } + + /** + * Calculates the SHA digest and returns the value as a hex string. + * + * @param data Data to digest + * @return SHA digest as a hex string + */ + public static String shaHex(String data) { + return new String(Hex.encodeHex(sha(data))); + } + +}