/* * JBoss, Home of Professional Open Source. * Copyright 2014 Red Hat, Inc., and individual contributors * as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.undertow.security.impl; import static io.undertow.UndertowMessages.MESSAGES; import io.undertow.security.api.SessionNonceManager; import io.undertow.server.HttpServerExchange; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; import org.xnio.XnioExecutor; import org.xnio.XnioExecutor.Key; import org.xnio.XnioIoThread; import io.undertow.util.FlexBase64; import io.undertow.util.WorkerUtils; /** * A default {@link io.undertow.security.api.NonceManager} implementation to provide reasonable single host management of nonces. * * This {@link io.undertow.security.api.NonceManager} manages nonces in two groups, the first is the group that are allocated to new requests, this group * is a problem as we want to be able to limit how many we distribute so we don't have a DOS storing too many but we also don't * a high number of requests to to push the other valid nonces out faster than they can be used. * * The second group is the set of nonces actively in use - these should be maintained as we can also maintain the nonce count * and even track the next nonce once invalid. * * Maybe group one should be a timestamp and private key hashed together, if used with a nonce count they move to be tracked to * ensure the same count is not used again - if successfully used without a nonce count add to a blacklist until expiration? A * nonce used without a nonce count will essentially be single use with each request getting a new nonce. * * @author <a href="mailto:darran.lofthouse@jboss.com">Darran Lofthouse</a> */ public class SimpleNonceManager implements SessionNonceManager { private static final String DEFAULT_HASH_ALG = "MD5"; /** * List of invalid nonces, this list contains the nonces that have been used without a nonce count. * * In that situation they are considered single use and must not be used again. */ private final Set<String> invalidNonces = Collections.synchronizedSet(new HashSet<String>()); /** * Map of known currently valid nonces, a SortedMap is used to order the nonces by their creation time stamp allowing a * simple iteration over the keys to identify expired nonces. */ private final Map<String, Nonce> knownNonces = Collections.synchronizedMap(new HashMap<String, Nonce>()); /** * A WeakHashMap to map expired nonces to their replacement nonce. For an item to be added to this Collection the value will * have been removed from the knownNonces map. * * A replacement nonce will have been added to knownNonces that references the key used here - once the replacement nonce is * removed from knownNonces then the key will be eligible for garbage collection allowing it to be removed from this map as * well. * * The value in this Map is a plain String, this is to avoid inadvertently creating a long term reference to the key we * expect to be garbage collected at some point in the future. */ private final Map<NonceHolder, String> forwardMapping = Collections.synchronizedMap(new WeakHashMap<NonceHolder, String>()); /** * A pseudo-random generator for creating the nonces, a secure random is not required here as this is used purely to * minimise the chance of collisions should two nonces be generated at exactly the same time. */ private final Random random = new Random(); private final String secret; private final String hashAlg; private final int hashLength; /** * After a nonce is issued the first authentication response MUST be received within 5 minutes. */ private static final long firstUseTimeOut = 5 * 60 * 1000; /** * Overall a nonce is valid from 15 minutes from first being issued, if used after this then a new nonce will be issued. */ private static final long overallTimeOut = 15 * 60 * 1000; /** * A previously used nonce will be allowed to remain in the knownNonces list for up to 5 minutes. * * The nonce will be accepted during this 5 minute window but will immediately be replaced causing any additional requests * to be forced to use the new nonce. * * This is primarily for session based digests where loosing the cached session key would be bad. */ private static final long cacheTimePostExpiry = 5 * 60 * 1000; public SimpleNonceManager() { this(DEFAULT_HASH_ALG); } public SimpleNonceManager(final String hashAlg) { // Verify it is a valid algorithm (at least for now) MessageDigest digest = getDigest(hashAlg); this.hashAlg = hashAlg; this.hashLength = digest.getDigestLength(); // Create a new secret only valid within this NonceManager instance. Random rand = new SecureRandom(); byte[] secretBytes = new byte[32]; rand.nextBytes(secretBytes); secret = FlexBase64.encodeString(digest.digest(secretBytes), false); } private MessageDigest getDigest(final String hashAlg) { try { return MessageDigest.getInstance(hashAlg); } catch (NoSuchAlgorithmException e) { throw MESSAGES.hashAlgorithmNotFound(hashAlg); } } /** * * @see io.undertow.security.api.NonceManager#nextNonce(java.lang.String, io.undertow.server.HttpServerExchange) */ public String nextNonce(String lastNonce, HttpServerExchange exchange) { if (lastNonce == null) { return createNewNonceString(); } if (invalidNonces.contains(lastNonce)) { // The nonce supplied has already been used. return createNewNonceString(); } String nonce = lastNonce; // Loop the forward mappings. synchronized (forwardMapping) { NonceHolder holder = new NonceHolder(lastNonce); while (forwardMapping.containsKey(holder)) { nonce = forwardMapping.get(holder); // The final NonceHolder will then be used if a forwardMapping needs to be set. holder = new NonceHolder(nonce); } synchronized (knownNonces) { Nonce value = knownNonces.get(nonce); if (value == null) { // Not a likely scenario but if this occurs then most likely the nonce mapped to has also expired so we will // just send a new nonce. nonce = createNewNonceString(); } else { long now = System.currentTimeMillis(); // The cacheTimePostExpiry is not included here as this is our opportunity to inform the client to use a // replacement nonce without a stale round trip. long earliestAccepted = now - firstUseTimeOut; if (value.timeStamp < earliestAccepted || value.timeStamp > now) { Nonce replacement = createNewNonce(holder); if (value.executorKey != null) { // The outcome doesn't matter - if we have the value we have all we need. value.executorKey.remove(); } nonce = replacement.nonce; // Create a record of the forward mapping so if any requests do need to be marked stale they can be // pointed towards the correct nonce to use. forwardMapping.put(holder, nonce); // Bring over any existing session key. replacement.setSessionKey(value.getSessionKey()); // At this point we will not accept the nonce again so remove it from the list of known nonces but do // register the replacement. knownNonces.remove(holder.nonce); // There are two reasons for registering the replacement 1 - to preserve any session key, 2 - To keep a // reference to the now invalid key so it // can be used as a key in a weak hash map. knownNonces.put(nonce, replacement); earliestAccepted = now - (overallTimeOut + cacheTimePostExpiry); long timeTillExpiry = replacement.timeStamp - earliestAccepted; replacement.executorKey = WorkerUtils.executeAfter(exchange.getIoThread(), new KnownNonceCleaner(nonce), timeTillExpiry, TimeUnit.MILLISECONDS); } } } } return nonce; } private String createNewNonceString() { return createNewNonce(null).nonce; } private Nonce createNewNonce(NonceHolder previousNonce) { byte[] prefix = new byte[8]; random.nextBytes(prefix); long timeStamp = System.currentTimeMillis(); byte[] now = Long.toString(timeStamp).getBytes(StandardCharsets.UTF_8); String nonce = createNonce(prefix, now); return new Nonce(nonce, timeStamp, previousNonce); } /** * * @see io.undertow.security.api.NonceManager#validateNonce(java.lang.String, int, io.undertow.server.HttpServerExchange) */ @Override public boolean validateNonce(String nonce, int nonceCount, HttpServerExchange exchange) { if (nonceCount < 0) { if (invalidNonces.contains(nonce)) { // Without a nonce count the nonce is only usable once. return false; } // Not already known so will drop into first use validation. } else if (knownNonces.containsKey(nonce)) { // At this point we need to validate that the nonce is still within it's time limits, // If a new nonce had been selected then a known nonce would not have been found. // The nonce will also have it's nonce count checked. return validateNonceWithCount(new Nonce(nonce), nonceCount, exchange.getIoThread()); } else if (forwardMapping.containsKey(new NonceHolder(nonce))) { // We could have let this drop through as the next validation would fail anyway but // why waste the time if we already know a replacement nonce has been issued. return false; } // This is not a nonce currently known to us so start the validation process. Nonce value = verifyUnknownNonce(nonce, nonceCount); if (value == null) { return false; } long now = System.currentTimeMillis(); // NOTE - This check is for the first use, overall validity is checked in validateNonceWithCount. long earliestAccepted = now - firstUseTimeOut; if (value.timeStamp < earliestAccepted || value.timeStamp > now) { // The embedded timestamp is either expired or somehow is after now. return false; } if (nonceCount < 0) { // Allow a single use but reject all further uses. return addInvalidNonce(value, exchange.getIoThread()); } else { return validateNonceWithCount(value, nonceCount, exchange.getIoThread()); } } private boolean validateNonceWithCount(Nonce nonce, int nonceCount, final XnioIoThread executor) { // This point could have been reached either because the knownNonces map contained the key or because // it didn't and a count was supplied - either way need to double check the contents of knownNonces once // the lock is in place. synchronized (knownNonces) { Nonce value = knownNonces.get(nonce.nonce); long now = System.currentTimeMillis(); // For the purpose of this validation we also add the cacheTimePostExpiry - when nextNonce is subsequently // called it will decide if we are in the interval to replace the nonce. long earliestAccepted = now - (overallTimeOut + cacheTimePostExpiry); if (value == null) { if (nonce.timeStamp < 0) { // Means it was in there, now it isn't - most likely a timestamp expiration mid check - abandon validation. return false; } if (nonce.timeStamp > earliestAccepted && nonce.timeStamp <= now) { knownNonces.put(nonce.nonce, nonce); long timeTillExpiry = nonce.timeStamp - earliestAccepted; nonce.executorKey = WorkerUtils.executeAfter(executor, new KnownNonceCleaner(nonce.nonce), timeTillExpiry, TimeUnit.MILLISECONDS); return true; } return false; } else { // We have it, just need to verify that it has not expired and that the nonce key is valid. if (value.timeStamp < earliestAccepted || value.timeStamp > now) { // The embedded timestamp is either expired or somehow is after now!! return false; } if (value.getMaxNonceCount() < nonceCount) { value.setMaxNonceCount(nonceCount); return true; } return false; } } } private boolean addInvalidNonce(final Nonce nonce, final XnioExecutor executor) { long now = System.currentTimeMillis(); long invalidBefore = now - firstUseTimeOut; long timeTillInvalid = nonce.timeStamp - invalidBefore; if (timeTillInvalid > 0) { if (invalidNonces.add(nonce.nonce)) { executor.executeAfter(new InvalidNonceCleaner(nonce.nonce), timeTillInvalid, TimeUnit.MILLISECONDS); return true; } else { return false; } } else { // So close to expiring any record of this nonce being used could have been cleared so // don't take a chance and just say no. return false; } } /** * Verify a previously unknown nonce and return the {@link NonceKey} representation for the nonce. * * Later when a nonce is re-used we can match based on the String alone - the information embedded within the nonce will be * cached with it. * * This stage of the verification simply extracts the prefix and the embedded timestamp and recreates a new hashed and * Base64 nonce based on the local secret - if the newly generated nonce matches the supplied one we accept it was created * by this nonce manager. * * This verification does not validate that the timestamp is within a valid time period. * * @param nonce - * @return */ private Nonce verifyUnknownNonce(final String nonce, final int nonceCount) { byte[] complete; int offset; int length; try { ByteBuffer decode = FlexBase64.decode(nonce); complete = decode.array(); offset = decode.arrayOffset(); length = decode.limit() - offset; } catch (IOException e) { throw MESSAGES.invalidBase64Token(e); } int timeStampLength = complete[offset + 8]; // A sanity check to try and verify the sizes we expect from the arrays are correct. if (hashLength > 0) { int expectedLength = 9 + timeStampLength + hashLength; if (length != expectedLength) { throw MESSAGES.invalidNonceReceived(); } else if (timeStampLength + 1 >= length) throw MESSAGES.invalidNonceReceived(); } byte[] prefix = new byte[8]; System.arraycopy(complete, offset, prefix, 0, 8); byte[] timeStampBytes = new byte[timeStampLength]; System.arraycopy(complete, offset + 9, timeStampBytes, 0, timeStampBytes.length); String expectedNonce = createNonce(prefix, timeStampBytes); if (expectedNonce.equals(nonce)) { try { long timeStamp = Long.parseLong(new String(timeStampBytes, StandardCharsets.UTF_8)); return new Nonce(expectedNonce, timeStamp, nonceCount); } catch (NumberFormatException dropped) { } } return null; } private String createNonce(final byte[] prefix, final byte[] timeStamp) { byte[] hashedPart = generateHash(prefix, timeStamp); byte[] complete = new byte[9 + timeStamp.length + hashedPart.length]; System.arraycopy(prefix, 0, complete, 0, 8); complete[8] = (byte) timeStamp.length; System.arraycopy(timeStamp, 0, complete, 9, timeStamp.length); System.arraycopy(hashedPart, 0, complete, 9 + timeStamp.length, hashedPart.length); return FlexBase64.encodeString(complete, false); } private byte[] generateHash(final byte[] prefix, final byte[] timeStamp) { MessageDigest digest = getDigest(hashAlg); digest.update(prefix); digest.update(timeStamp); return digest.digest(secret.getBytes(StandardCharsets.UTF_8)); } public void associateHash(String nonce, byte[] hash) { // TODO Auto-generated method stub } public byte[] lookupHash(String nonce) { // TODO Auto-generated method stub return null; } /** * A simple wrapper around a nonce to allow it to be used as a key in a weak map. */ private static class NonceHolder { private final String nonce; private NonceHolder(final String nonce) { if (nonce == null) { throw new NullPointerException("nonce must not be null."); } this.nonce = nonce; } @Override public int hashCode() { return nonce.hashCode(); } @Override public boolean equals(Object obj) { return (obj instanceof NonceHolder) ? nonce.equals(((NonceHolder) obj).nonce) : false; } } /** * The state associated with a nonce. * * A NonceKey for a preciously valid nonce is also referenced, this is so that a WeakHashMap can be used to maintain a * mapping from the original NonceKey to the new nonce value. */ private static class Nonce { private final String nonce; private final long timeStamp; // TODO we will also add a mechanism to track the gaps as the only restriction is that a NC can only be used one. private int maxNonceCount; // We keep this as it is used in the weak hash map as a forward mapping as long as the nonce to map to is still alive. @SuppressWarnings("unused") private final NonceHolder previousNonce; private byte[] sessionKey; private Key executorKey; private Nonce(final String nonce) { this(nonce, -1, -1); } private Nonce(final String nonce, final long timeStamp) { this(nonce, timeStamp, -1); } private Nonce(final String nonce, final long timeStamp, final int initialNC) { this(nonce, timeStamp, initialNC, null); } private Nonce(final String nonce, final long timeStamp, final NonceHolder previousNonce) { this(nonce, timeStamp, -1, previousNonce); } private Nonce(final String nonce, final long timeStamp, final int initialNC, final NonceHolder previousNonce) { this.nonce = nonce; this.timeStamp = timeStamp; this.maxNonceCount = initialNC; this.previousNonce = previousNonce; } byte[] getSessionKey() { return sessionKey; } void setSessionKey(final byte[] sessionKey) { this.sessionKey = sessionKey; } int getMaxNonceCount() { return maxNonceCount; } void setMaxNonceCount(int maxNonceCount) { this.maxNonceCount = maxNonceCount; } } private class InvalidNonceCleaner implements Runnable { private final String nonce; private InvalidNonceCleaner(final String nonce) { if (nonce == null) { throw new NullPointerException("nonce must not be null."); } this.nonce = nonce; } public void run() { invalidNonces.remove(nonce); } } private class KnownNonceCleaner implements Runnable { private final String nonce; private KnownNonceCleaner(final String nonce) { if (nonce == null) { throw new NullPointerException("nonce must not be null."); } this.nonce = nonce; } public void run() { knownNonces.remove(nonce); } } }