001/* 002 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * jcarsique 018 */ 019package org.nuxeo.common.codec; 020 021import java.io.File; 022import java.io.FileInputStream; 023import java.io.FileOutputStream; 024import java.io.IOException; 025import java.io.InputStream; 026import java.io.OutputStream; 027import java.nio.ByteBuffer; 028import java.nio.CharBuffer; 029import java.nio.charset.Charset; 030import java.security.GeneralSecurityException; 031import java.security.InvalidKeyException; 032import java.security.KeyStore; 033import java.security.KeyStoreException; 034import java.security.MessageDigest; 035import java.security.NoSuchAlgorithmException; 036import java.security.Security; 037import java.security.KeyStore.PasswordProtection; 038import java.util.Arrays; 039import java.util.HashMap; 040import java.util.Map; 041import java.util.regex.Matcher; 042import java.util.regex.Pattern; 043 044import javax.crypto.BadPaddingException; 045import javax.crypto.Cipher; 046import javax.crypto.IllegalBlockSizeException; 047import javax.crypto.NoSuchPaddingException; 048import javax.crypto.SecretKey; 049import javax.crypto.spec.SecretKeySpec; 050 051import org.apache.commons.codec.binary.Base64; 052import org.apache.commons.lang.StringUtils; 053import org.apache.commons.logging.Log; 054import org.apache.commons.logging.LogFactory; 055 056/** 057 * Supported algorithms (name, keysize): 058 * <ul> 059 * <li>AES/ECB/PKCS5Padding (128)</li> 060 * <li>DES/ECB/PKCS5Padding (64)</li> 061 * <ul/> 062 * 063 * @since 7.4 064 */ 065public class Crypto { 066 067 protected static final Pattern CRYPTO_PATTERN = Pattern.compile("\\{\\$(?<algo>.*)\\$(?<value>.+)\\}"); 068 069 private static final Log log = LogFactory.getLog(Crypto.class); 070 071 public static final String AES = "AES"; 072 073 public static final String AES_ECB_PKCS5PADDING = "AES/ECB/PKCS5Padding"; 074 075 public static final String DES = "DES"; 076 077 public static final String DES_ECB_PKCS5PADDING = "DES/ECB/PKCS5Padding"; 078 079 public static final String[] IMPLEMENTED_ALGOS = { AES, DES, AES_ECB_PKCS5PADDING, DES_ECB_PKCS5PADDING }; 080 081 public static final String DEFAULT_ALGO = AES_ECB_PKCS5PADDING; 082 083 private static final String SHA1 = "SHA-1"; 084 085 private final byte[] secretKey; 086 087 private final Map<String, SecretKey> secretKeys = new HashMap<>(); 088 089 private boolean initialized = true; 090 091 private final byte[] digest; 092 093 /** 094 * @param secretKey 095 */ 096 public Crypto(byte[] secretKey) { 097 this.secretKey = secretKey; 098 digest = getSHA1DigestOrEmpty(secretKey); 099 if (digest.length == 0) { 100 clear(); 101 } 102 } 103 104 /** 105 * Initialize cryptography with a map of {@link SecretKey}. 106 * 107 * @param secretKeys Map of {@code SecretKey} per algorithm 108 */ 109 public Crypto(Map<String, SecretKey> secretKeys) { 110 this(secretKeys, Crypto.class.getName().toCharArray()); 111 } 112 113 /** 114 * Initialize cryptography with a map of {@link SecretKey}. 115 * 116 * @param digest Digest for later use by {@link #verifyKey(byte[])} 117 * @param secretKeys Map of {@code SecretKey} per algorithm 118 */ 119 public Crypto(Map<String, SecretKey> secretKeys, char[] digest) { 120 secretKey = new byte[0]; 121 this.digest = getSHA1DigestOrEmpty(getBytes(digest)); 122 this.secretKeys.putAll(secretKeys); 123 if (this.digest.length == 0) { 124 clear(); 125 } 126 } 127 128 /** 129 * Initialize cryptography with a keystore. 130 * 131 * @param keystorePath Path to the keystore. 132 * @param keystorePass Keystore password. It is also used to generate the digest for {@link #verifyKey(byte[])} 133 * @param keyAlias Key alias prefix. It is suffixed with the algorithm. 134 * @param keyPass Key password 135 * @throws IOException 136 * @throws GeneralSecurityException 137 */ 138 public Crypto(String keystorePath, char[] keystorePass, String keyAlias, char[] keyPass) 139 throws GeneralSecurityException, IOException { 140 this(Crypto.getKeysFromKeyStore(keystorePath, keystorePass, keyAlias, keyPass), keystorePass); 141 } 142 143 private final static class NO_OP extends Crypto { 144 private NO_OP() { 145 super(new byte[0]); 146 } 147 148 @Override 149 public String encrypt(String algorithm, byte[] bytesToEncrypt) throws GeneralSecurityException { 150 return null; 151 } 152 153 @Override 154 public byte[] decrypt(String strToDecrypt) { 155 return strToDecrypt.getBytes(); 156 } 157 158 @Override 159 public void clear() { 160 // NO OP 161 } 162 }; 163 164 public static final Crypto NO_OP = new NO_OP(); 165 166 protected SecretKey getSecretKey(String algorithm, byte[] key) throws NoSuchAlgorithmException { 167 if (!initialized) { 168 throw new RuntimeException("The Crypto object has been cleared."); 169 } 170 if (AES_ECB_PKCS5PADDING.equals(algorithm)) { 171 algorithm = AES; // AES_ECB_PKCS5PADDING is the default for AES 172 } else if (DES_ECB_PKCS5PADDING.equals(algorithm)) { 173 algorithm = DES; // DES_ECB_PKCS5PADDING is the default for DES 174 } 175 if (!secretKeys.containsKey(algorithm)) { 176 if (secretKey.length == 0) { 177 throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm); 178 } 179 if (AES.equals(algorithm)) { // default for AES 180 key = Arrays.copyOf(getSHA1Digest(key), 16); // use a 128 bits secret key 181 secretKeys.put(AES, new SecretKeySpec(key, AES)); 182 } else if (DES.equals(algorithm)) { // default for DES 183 key = Arrays.copyOf(getSHA1Digest(key), 8); // use a 64 bits secret key 184 secretKeys.put(DES, new SecretKeySpec(key, DES)); 185 } else { 186 throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm); 187 } 188 } 189 return secretKeys.get(algorithm); 190 } 191 192 public byte[] getSHA1Digest(final byte[] key) throws NoSuchAlgorithmException { 193 MessageDigest sha = MessageDigest.getInstance(SHA1); 194 return sha.digest(key); 195 } 196 197 public byte[] getSHA1DigestOrEmpty(final byte[] bytes) { 198 byte[] aDigest = new byte[0]; 199 try { 200 aDigest = getSHA1Digest(bytes); 201 } catch (NoSuchAlgorithmException e) { 202 log.error(e); 203 } 204 return aDigest; 205 } 206 207 /** 208 * @param bytesToEncrypt 209 * @throws GeneralSecurityException 210 */ 211 public String encrypt(byte[] bytesToEncrypt) throws GeneralSecurityException { 212 return encrypt(null, bytesToEncrypt); 213 } 214 215 /** 216 * @param algorithm cipher transformation of the form "algorithm/mode/padding" or "algorithm". See the Cipher 217 * section in the <a 218 * href=http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#Cipher>Java 219 * Cryptography Architecture Standard Algorithm Name Documentation</a>. 220 * @param bytesToEncrypt 221 * @throws NoSuchPaddingException if {@code algorithm} contains a padding scheme that is not available. 222 * @throws NoSuchAlgorithmException if {@code algorithm} is in an invalid or not supported format. 223 * @throws GeneralSecurityException 224 */ 225 public String encrypt(String algorithm, byte[] bytesToEncrypt) throws GeneralSecurityException { 226 final String encryptedAlgo; 227 if (StringUtils.isBlank(algorithm)) { 228 algorithm = DEFAULT_ALGO; 229 encryptedAlgo = ""; 230 } else { 231 encryptedAlgo = Base64.encodeBase64String(algorithm.getBytes()); 232 } 233 Cipher cipher = Cipher.getInstance(algorithm); 234 cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(algorithm, secretKey)); 235 final String encryptedString = Base64.encodeBase64String(cipher.doFinal(bytesToEncrypt)); 236 return String.format("{$%s$%s}", encryptedAlgo, encryptedString); 237 } 238 239 /** 240 * The method returns either the decrypted {@code strToDecrypt}, either the {@code strToDecrypt} itself if it is not 241 * recognized as a crypted string or if the decryption fails. The return value is a byte array for security purpose, 242 * it is your responsibility to convert it then to a String or not (use of {@code char[]} is recommended). 243 * 244 * @param strToDecrypt 245 * @return the decrypted {@code strToDecrypt} as an array of bytes, never {@code null} 246 * @see #getChars(byte[]) 247 */ 248 public byte[] decrypt(String strToDecrypt) { 249 Matcher matcher = CRYPTO_PATTERN.matcher(strToDecrypt); 250 if (!matcher.matches()) { 251 return strToDecrypt.getBytes(); 252 } 253 Cipher decipher; 254 try { 255 String algorithm = new String(Base64.decodeBase64(matcher.group("algo"))); 256 if (StringUtils.isBlank(algorithm)) { 257 algorithm = DEFAULT_ALGO; 258 } 259 decipher = Cipher.getInstance(algorithm); 260 decipher.init(Cipher.DECRYPT_MODE, getSecretKey(algorithm, secretKey)); 261 final byte[] decryptedString = decipher.doFinal(Base64.decodeBase64(matcher.group("value"))); 262 return decryptedString; 263 } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { 264 log.trace("Available algorithms: " + Security.getAlgorithms("Cipher")); 265 log.trace("Available security providers: " + Arrays.asList(Security.getProviders())); 266 log.debug(e, e); 267 } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { 268 log.debug(e, e); 269 } 270 return strToDecrypt.getBytes(); 271 } 272 273 /** 274 * Clear sensible values. That makes the current object unusable. 275 */ 276 public void clear() { 277 Arrays.fill(secretKey, (byte) 0); 278 Arrays.fill(digest, (byte) 0); 279 secretKeys.clear(); 280 initialized = false; 281 } 282 283 @Override 284 protected void finalize() throws Throwable { 285 clear(); 286 super.finalize(); 287 } 288 289 /** 290 * Test the given {@code candidateDigest} against the configured digest. In case of failure, the secret data is 291 * destroyed and the object is made unusable.<br> 292 * Use that method to check if some code is allowed to request that Crypto object. 293 * 294 * @param candidateDigest 295 * @return true if {@code candidateDigest} matches the one used on creation. 296 * @see #clear() 297 * @see #verifyKey(char[]) 298 */ 299 public boolean verifyKey(byte[] candidateDigest) { 300 boolean success = Arrays.equals(getSHA1DigestOrEmpty(candidateDigest), digest); 301 if (!success) { 302 clear(); 303 } 304 return success; 305 } 306 307 /** 308 * Test the given {@code candidateDigest} against the configured digest. In case of failure, the secret data is 309 * destroyed and the object is made unusable.<br> 310 * Use that method to check if some code is allowed to request that Crypto object. 311 * 312 * @param candidateDigest 313 * @return true if {@code candidateDigest} matches the one used on creation. 314 * @see #clear() 315 * @see #verifyKey(byte[]) 316 */ 317 public boolean verifyKey(char[] candidateDigest) { 318 return verifyKey(getBytes(candidateDigest)); 319 } 320 321 /** 322 * Utility method to get {@code byte[]} from {@code char[]} since it is recommended to store passwords in 323 * {@code char[]} rather than in {@code String}.<br> 324 * The default charset of this Java virtual machine is used. There can be conversion issue with unmappable 325 * characters: they will be replaced with the charset's default replacement string. 326 * 327 * @param chars char array to convert 328 * @return the byte array converted from {@code chars} using the default charset. 329 */ 330 public static byte[] getBytes(char[] chars) { 331 CharBuffer charBuffer = CharBuffer.wrap(chars); 332 ByteBuffer byteBuffer = Charset.defaultCharset().encode(charBuffer); 333 return Arrays.copyOfRange(byteBuffer.array(), 0, byteBuffer.limit()); 334 } 335 336 /** 337 * Utility method to get {@code char[]} from {@code bytes[]} since it is recommended to store passwords in 338 * {@code char[]} rather than in {@code String}.<br> 339 * The default charset of this Java virtual machine is used. There can be conversion issue with unmappable 340 * characters: they will be replaced with the charset's default replacement string. 341 * 342 * @param bytes byte array to convert 343 * @return the char array converted from {@code bytes} using the default charset. 344 */ 345 public static char[] getChars(byte[] bytes) { 346 ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); 347 CharBuffer charBuffer = Charset.defaultCharset().decode(byteBuffer); 348 return Arrays.copyOfRange(charBuffer.array(), 0, charBuffer.limit()); 349 } 350 351 /** 352 * @param value 353 * @return true if the given {@code value} is encrypted 354 */ 355 public static boolean isEncrypted(String value) { 356 return value != null && CRYPTO_PATTERN.matcher(value).matches(); 357 } 358 359 /** 360 * Extract secret keys from a keystore looking for {@code keyAlias + algorithm} 361 * 362 * @param keystorePath Path to the keystore 363 * @param keystorePass Keystore password 364 * @param keyAlias Key alias prefix. It is suffixed with the algorithm. 365 * @param keyPass Key password 366 * @throws GeneralSecurityException 367 * @throws IOException 368 * @see #IMPLEMENTED_ALGOS 369 */ 370 public static Map<String, SecretKey> getKeysFromKeyStore(String keystorePath, char[] keystorePass, String keyAlias, 371 char[] keyPass) throws GeneralSecurityException, IOException { 372 KeyStore keystore = KeyStore.getInstance("JCEKS"); 373 try (InputStream keystoreStream = new FileInputStream(keystorePath)) { 374 keystore.load(keystoreStream, keystorePass); 375 } 376 Map<String, SecretKey> secretKeys = new HashMap<>(); 377 for (String algo : IMPLEMENTED_ALGOS) { 378 if (keystore.containsAlias(keyAlias + algo)) { 379 SecretKey key = (SecretKey) keystore.getKey(keyAlias + algo, keyPass); 380 secretKeys.put(algo, key); 381 } 382 } 383 if (secretKeys.isEmpty()) { 384 throw new KeyStoreException(String.format("No alias \"%s<algo>\" found in %s", keyAlias, keystorePath)); 385 } 386 return secretKeys; 387 } 388 389 /** 390 * Store a key in a keystore.<br> 391 * The keystore is created if it doesn't exist. 392 * 393 * @param keystorePath Path to the keystore 394 * @param keystorePass Keystore password 395 * @param keyAlias Key alias prefix. It must be suffixed with the algorithm ({@link SecretKey#getAlgorithm()} is 396 * fine). 397 * @param keyPass Key password 398 * @param key 399 * @throws GeneralSecurityException 400 * @throws IOException 401 * @see #IMPLEMENTED_ALGOS 402 */ 403 public static void setKeyInKeyStore(String keystorePath, char[] keystorePass, String keyAlias, char[] keyPass, 404 SecretKey key) throws GeneralSecurityException, IOException { 405 KeyStore keystore = KeyStore.getInstance("JCEKS"); 406 if (!new File(keystorePath).exists()) { 407 log.info("Creating a new JCEKS keystore at " + keystorePath); 408 keystore.load(null); 409 } else { 410 try (InputStream keystoreStream = new FileInputStream(keystorePath)) { 411 keystore.load(keystoreStream, keystorePass); 412 } 413 } 414 KeyStore.SecretKeyEntry keyStoreEntry = new KeyStore.SecretKeyEntry(key); 415 PasswordProtection keyPassword = new PasswordProtection(keyPass); 416 keystore.setEntry(keyAlias, keyStoreEntry, keyPassword); 417 try (OutputStream keystoreStream = new FileOutputStream(keystorePath)) { 418 keystore.store(keystoreStream, keystorePass); 419 } 420 } 421 422}