Ltpa2Utils.java

/*
 * Copyright 2018 Ronny "Sephiroth" Perinke <[email protected]>
 *
 * 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
 *
 *      https://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 de.sephirothj.spring.security.ltpa2;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import lombok.experimental.UtilityClass;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import org.springframework.util.Base64Utils;

/**
 * Utility class for operations on an LTPA2 token
 *
 * @author Sephiroth
 */
@UtilityClass
public class Ltpa2Utils
{
	private static final String TOKEN_ENCRYPTION_ALGORITHM = "AES/CBC/PKCS5Padding";
	private static final String SIGNATURE_ALGORITM = "SHA1withRSA";
	private static final String SIGNATURE_DIGEST_METHOD = "SHA";
	private static final String TOKEN_MUST_NOT_BE_EMPTY = "token must not be empty";
	private static final String KEY_MUST_NOT_BE_NULL = "key must not be null";
	private static final String TOKEN_IS_MALFORMED = "token is malformed";

	/**
	 * decrypts an base64-encoded LTPA2 token
	 *
	 * @param encryptedToken the base64-encoded and encrypted token
	 * @param key the shared secret key that was used to encrypt {@code encryptedToken}
	 * @return the serialized token
	 * @throws InvalidLtpa2TokenException in case something went wrong
	 */
	@NonNull
	public String decryptLtpa2Token(@NonNull final String encryptedToken, @NonNull final SecretKey key) throws InvalidLtpa2TokenException
	{
		Assert.hasText(encryptedToken, TOKEN_MUST_NOT_BE_EMPTY);
		Assert.notNull(key, KEY_MUST_NOT_BE_NULL);

		try
		{
			final byte[] rawToken = Base64Utils.decodeFromString(encryptedToken);
			final Cipher c = Cipher.getInstance(TOKEN_ENCRYPTION_ALGORITHM);
			final IvParameterSpec iv = new IvParameterSpec(key.getEncoded());
			c.init(Cipher.DECRYPT_MODE, key, iv);
			final byte[] rawDecodedToken = c.doFinal(rawToken);
			return new String(rawDecodedToken, StandardCharsets.UTF_8);
		}
		catch (InvalidKeyException | NoSuchAlgorithmException | BadPaddingException | IllegalBlockSizeException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalArgumentException ex)
		{
			throw new InvalidLtpa2TokenException("failed to decrypt LTPA2 token", ex);
		}
	}

	/**
	 * returns the three parts of the given LTPA2 token
	 *
	 * @param token a serialized LTPA2 token (unencrypted)
	 * @return Array with length 3. Index 0 = Body, 1 = expires and 2 = base64-encoded signature
	 * @throws IllegalArgumentException if the token is empty
	 * @throws InvalidLtpa2TokenException if the token is malformed
	 */
	@NonNull
	private String[] getTokenParts(@NonNull final String token) throws IllegalArgumentException
	{
		Assert.hasText(token, TOKEN_MUST_NOT_BE_EMPTY);

		final String[] tokenParts = token.split("\\%", 3);
		if (tokenParts.length != 3)
		{
			throw new InvalidLtpa2TokenException("invalid serialized LTPA2 token. token must contain exactly three '%'!");
		}
		return tokenParts;
	}

	/**
	 * create a new instance of {@linkplain Ltpa2Token} from the given serialized LTPA2 token
	 *
	 * @param tokenStr a serialized LTPA2 token (unencrypted)
	 * @return instance of {@linkplain Ltpa2Token}
	 * @throws InvalidLtpa2TokenException if the token is malformed
	 */
	@NonNull
	public Ltpa2Token makeInstance(@NonNull final String tokenStr) throws InvalidLtpa2TokenException
	{
		Assert.hasText(tokenStr, TOKEN_MUST_NOT_BE_EMPTY);

		try
		{
			final String[] tokenParts = getTokenParts(tokenStr);
			final Ltpa2Token token = Ltpa2Token.of(tokenParts[0]);
			if (token.getExpire() == null)
			{
				token.setExpire(tokenParts[1]);
			}
			return token;
		}
		catch (IllegalArgumentException e)
		{
			throw new InvalidLtpa2TokenException(TOKEN_IS_MALFORMED);
		}
	}

	/**
	 * checks if the given token is expired
	 *
	 * @param token a serialized LTPA2 token (unencrypted)
	 * @return whether the given token is expired or not
	 * @throws InvalidLtpa2TokenException if the token is malformed
	 * @see Ltpa2Token#isExpired()
	 */
	public boolean isTokenExpired(@NonNull final String token) throws InvalidLtpa2TokenException
	{
		Assert.hasText(token, TOKEN_MUST_NOT_BE_EMPTY);

		final Ltpa2Token instance = makeInstance(token);
		return instance.isExpired();
	}

	/**
	 * checks if the signature of the given token is valid
	 *
	 * @param token a serialized LTPA2 token (unencrypted)
	 * @param signerKey the base64-encoded public key which corresponds to the private key that was used to sign an LTPA2 token
	 * @return whether the signature for the given token is valid or not
	 * @throws InvalidLtpa2TokenException in case something went wrong when decoding {@code signerKey}
	 * @throws InvalidLtpa2TokenException if the token is malformed
	 * @throws InvalidLtpa2TokenException in case an error occured during signature verification
	 * @see LtpaKeyUtils#decodePublicKey(java.lang.String)
	 * @see #isSignatureValid(java.lang.String, java.security.PublicKey)
	 */
	boolean isSignatureValid(@NonNull final String token, @NonNull final String signerKey) throws InvalidLtpa2TokenException
	{
		Assert.hasText(token, TOKEN_MUST_NOT_BE_EMPTY);
		Assert.hasText(signerKey, "signerKey must not be empty");

		try
		{
			return isSignatureValid(token, LtpaKeyUtils.decodePublicKey(signerKey));
		}
		catch (GeneralSecurityException ex)
		{
			throw new InvalidLtpa2TokenException("invalid public key", ex);
		}
	}

	/**
	 * checks if the signature of the given token is valid
	 *
	 * @param token a serialized LTPA2 token (unencrypted)
	 * @param signerKey the public key which corresponds to the private key that was used to sign an LTPA2 token
	 * @return whether the signature for the given token is valid or not
	 * @throws InvalidLtpa2TokenException in case an error occured during signature verification
	 * @throws InvalidLtpa2TokenException if the token is malformed
	 */
	public boolean isSignatureValid(@NonNull final String token, @NonNull final PublicKey signerKey) throws InvalidLtpa2TokenException
	{
		Assert.hasText(token, TOKEN_MUST_NOT_BE_EMPTY);
		Assert.notNull(signerKey, KEY_MUST_NOT_BE_NULL);

		try
		{
			final String[] tokenParts = getTokenParts(token);
			final Signature signer = Signature.getInstance(SIGNATURE_ALGORITM);
			signer.initVerify(signerKey);
			final MessageDigest md = MessageDigest.getInstance(SIGNATURE_DIGEST_METHOD);
			final byte[] bodyHash = md.digest(tokenParts[0].getBytes());
			signer.update(bodyHash);
			return signer.verify(Base64Utils.decodeFromString(tokenParts[2]));
		}
		catch (InvalidKeyException | NoSuchAlgorithmException | SignatureException ex)
		{
			throw new InvalidLtpa2TokenException("failed to verify token signature", ex);
		}
		catch (IllegalArgumentException e)
		{
			throw new InvalidLtpa2TokenException(TOKEN_IS_MALFORMED);
		}
	}

	/**
	 * signs the given LTPA2 token
	 *
	 * @param token a serialized LTPA2 token (unencrypted)
	 * @param key the private key for signing the given token
	 * @return the base64-encoded signature of the token
	 * @throws InvalidLtpa2TokenException in case an error occured during signature creation
	 * @see Ltpa2Token#toString()
	 */
	@NonNull
	public String signToken(@NonNull final String token, @NonNull final PrivateKey key) throws InvalidLtpa2TokenException
	{
		Assert.hasText(token, TOKEN_MUST_NOT_BE_EMPTY);
		Assert.notNull(key, KEY_MUST_NOT_BE_NULL);

		try
		{
			final Signature signer = Signature.getInstance(SIGNATURE_ALGORITM);
			signer.initSign(key);
			final MessageDigest md = MessageDigest.getInstance(SIGNATURE_DIGEST_METHOD);
			final byte[] bodyHash = md.digest(token.getBytes());
			signer.update(bodyHash);
			return Base64Utils.encodeToString(signer.sign());
		}
		catch (InvalidKeyException | NoSuchAlgorithmException | SignatureException ex)
		{
			throw new InvalidLtpa2TokenException("failed to sign token", ex);
		}
	}

	/**
	 * create a serialized, signed and encrypted LTPA2 token
	 *
	 * @param token the token
	 * @param signerKey the private key for signing the given token
	 * @param key the shared secret key for encrypting the given token
	 * @return serialized, signed and encrypted LTPA2 token
	 * @throws InvalidLtpa2TokenException in case an error occured during signature creation
	 * @throws InvalidLtpa2TokenException in case an error occured during encrypting the token
	 */
	@NonNull
	public String encryptToken(@NonNull final Ltpa2Token token, @NonNull final PrivateKey signerKey, @NonNull final SecretKey key) throws InvalidLtpa2TokenException
	{
		Assert.notNull(token, "token must not be null");
		Assert.notNull(signerKey, "signerKey must not be null");
		Assert.notNull(key, KEY_MUST_NOT_BE_NULL);

		final String serializedToken = token.toString();
		final String signature = signToken(serializedToken, signerKey);
		final StringBuilder rawTokenStr = new StringBuilder(serializedToken);
		rawTokenStr.append('%').append(token.getAttribute(Ltpa2Token.EXPIRE_ATTRIBUTE_NAME)).append('%').append(signature);
		try
		{
			final Cipher c = Cipher.getInstance(TOKEN_ENCRYPTION_ALGORITHM);
			final IvParameterSpec iv = new IvParameterSpec(key.getEncoded());
			c.init(Cipher.ENCRYPT_MODE, key, iv);
			final byte[] rawEncryptedToken = c.doFinal(rawTokenStr.toString().getBytes(StandardCharsets.UTF_8));
			return Base64Utils.encodeToString(rawEncryptedToken);
		}
		catch (InvalidKeyException | NoSuchAlgorithmException | BadPaddingException | IllegalBlockSizeException | NoSuchPaddingException | InvalidAlgorithmParameterException ex)
		{
			throw new InvalidLtpa2TokenException("failed to encrypt token", ex);
		}
	}
}