1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.util; 18 19 import android.content.Context; 20 import android.content.SharedPreferences; 21 import android.os.Environment; 22 import android.os.storage.StorageManager; 23 import android.text.TextUtils; 24 25 import com.android.internal.annotations.VisibleForTesting; 26 27 import java.io.File; 28 import java.nio.charset.Charset; 29 import java.security.MessageDigest; 30 import java.security.NoSuchAlgorithmException; 31 import java.security.SecureRandom; 32 33 /** 34 * HashedStringCache provides hashing functionality with an underlying LRUCache and expiring salt. 35 * Salt and expiration time are being stored under the tag passed in by the calling package -- 36 * intended usage is the calling package name. 37 * @hide 38 */ 39 public class HashedStringCache { 40 private static HashedStringCache sHashedStringCache = null; 41 private static final Charset UTF_8 = Charset.forName("UTF-8"); 42 private static final int HASH_CACHE_SIZE = 100; 43 private static final int HASH_LENGTH = 8; 44 @VisibleForTesting 45 static final String HASH_SALT = "_hash_salt"; 46 @VisibleForTesting 47 static final String HASH_SALT_DATE = "_hash_salt_date"; 48 @VisibleForTesting 49 static final String HASH_SALT_GEN = "_hash_salt_gen"; 50 // For privacy we need to rotate the salt regularly 51 private static final long DAYS_TO_MILLIS = 1000 * 60 * 60 * 24; 52 private static final int MAX_SALT_DAYS = 100; 53 private final LruCache<String, String> mHashes; 54 private final SecureRandom mSecureRandom; 55 private final Object mPreferenceLock = new Object(); 56 private final MessageDigest mDigester; 57 private byte[] mSalt; 58 private int mSaltGen; 59 private SharedPreferences mSharedPreferences; 60 61 private static final String TAG = "HashedStringCache"; 62 private static final boolean DEBUG = false; 63 HashedStringCache()64 private HashedStringCache() { 65 mHashes = new LruCache<>(HASH_CACHE_SIZE); 66 mSecureRandom = new SecureRandom(); 67 try { 68 mDigester = MessageDigest.getInstance("MD5"); 69 } catch (NoSuchAlgorithmException impossible) { 70 // this can't happen - MD5 is always present 71 throw new RuntimeException(impossible); 72 } 73 } 74 75 /** 76 * @return - instance of the HashedStringCache 77 * @hide 78 */ getInstance()79 public static HashedStringCache getInstance() { 80 if (sHashedStringCache == null) { 81 sHashedStringCache = new HashedStringCache(); 82 } 83 return sHashedStringCache; 84 } 85 86 /** 87 * Take the string and context and create a hash of the string. Trigger refresh on salt if salt 88 * is more than 7 days old 89 * @param context - callers context to retrieve SharedPreferences 90 * @param clearText - string that needs to be hashed 91 * @param tag - class name to use for storing values in shared preferences 92 * @param saltExpirationDays - number of days we may keep the same salt 93 * special value -1 will short-circuit and always return null. 94 * @return - HashResult containing the hashed string and the generation of the hash salt, null 95 * if clearText string is empty 96 * 97 * @hide 98 */ hashString(Context context, String tag, String clearText, int saltExpirationDays)99 public HashResult hashString(Context context, String tag, String clearText, 100 int saltExpirationDays) { 101 if (saltExpirationDays == -1 || context == null 102 || TextUtils.isEmpty(clearText) || TextUtils.isEmpty(tag)) { 103 return null; 104 } 105 106 populateSaltValues(context, tag, saltExpirationDays); 107 String hashText = mHashes.get(clearText); 108 if (hashText != null) { 109 return new HashResult(hashText, mSaltGen); 110 } 111 112 mDigester.reset(); 113 mDigester.update(mSalt); 114 mDigester.update(clearText.getBytes(UTF_8)); 115 byte[] bytes = mDigester.digest(); 116 int len = Math.min(HASH_LENGTH, bytes.length); 117 hashText = Base64.encodeToString(bytes, 0, len, Base64.NO_PADDING | Base64.NO_WRAP); 118 mHashes.put(clearText, hashText); 119 120 return new HashResult(hashText, mSaltGen); 121 } 122 123 /** 124 * Populates the mSharedPreferences and checks if there is a salt present and if it's older than 125 * 7 days 126 * @param tag - class name to use for storing values in shared preferences 127 * @param saltExpirationDays - number of days we may keep the same salt 128 * @param saltDate - the date retrieved from configuration 129 * @return - true if no salt or salt is older than 7 days 130 */ checkNeedsNewSalt(String tag, int saltExpirationDays, long saltDate)131 private boolean checkNeedsNewSalt(String tag, int saltExpirationDays, long saltDate) { 132 if (saltDate == 0 || saltExpirationDays < -1) { 133 return true; 134 } 135 if (saltExpirationDays > MAX_SALT_DAYS) { 136 saltExpirationDays = MAX_SALT_DAYS; 137 } 138 long now = System.currentTimeMillis(); 139 long delta = now - saltDate; 140 // Check for delta < 0 to make sure we catch if someone puts their phone far in the 141 // future and then goes back to normal time. 142 return delta >= saltExpirationDays * DAYS_TO_MILLIS || delta < 0; 143 } 144 145 /** 146 * Populate the salt and saltGen member variables if they aren't already set / need refreshing. 147 * @param context - to get sharedPreferences 148 * @param tag - class name to use for storing values in shared preferences 149 * @param saltExpirationDays - number of days we may keep the same salt 150 */ populateSaltValues(Context context, String tag, int saltExpirationDays)151 private void populateSaltValues(Context context, String tag, int saltExpirationDays) { 152 synchronized (mPreferenceLock) { 153 // check if we need to refresh the salt 154 mSharedPreferences = getHashSharedPreferences(context); 155 long saltDate = mSharedPreferences.getLong(tag + HASH_SALT_DATE, 0); 156 boolean needsNewSalt = checkNeedsNewSalt(tag, saltExpirationDays, saltDate); 157 if (needsNewSalt) { 158 mHashes.evictAll(); 159 } 160 if (mSalt == null || needsNewSalt) { 161 String saltString = mSharedPreferences.getString(tag + HASH_SALT, null); 162 mSaltGen = mSharedPreferences.getInt(tag + HASH_SALT_GEN, 0); 163 if (saltString == null || needsNewSalt) { 164 mSaltGen++; 165 byte[] saltBytes = new byte[16]; 166 mSecureRandom.nextBytes(saltBytes); 167 saltString = Base64.encodeToString(saltBytes, 168 Base64.NO_PADDING | Base64.NO_WRAP); 169 mSharedPreferences.edit() 170 .putString(tag + HASH_SALT, saltString) 171 .putInt(tag + HASH_SALT_GEN, mSaltGen) 172 .putLong(tag + HASH_SALT_DATE, System.currentTimeMillis()).apply(); 173 if (DEBUG) { 174 Log.d(TAG, "created a new salt: " + saltString); 175 } 176 } 177 mSalt = saltString.getBytes(UTF_8); 178 } 179 } 180 } 181 182 /** 183 * Android:ui doesn't have persistent preferences, so need to fall back on this hack originally 184 * from ChooserActivity.java 185 * @param context 186 * @return 187 */ getHashSharedPreferences(Context context)188 private SharedPreferences getHashSharedPreferences(Context context) { 189 final File prefsFile = new File(new File( 190 Environment.getDataUserCePackageDirectory( 191 StorageManager.UUID_PRIVATE_INTERNAL, 192 context.getUserId(), context.getPackageName()), 193 "shared_prefs"), 194 "hashed_cache.xml"); 195 return context.getSharedPreferences(prefsFile, Context.MODE_PRIVATE); 196 } 197 198 /** 199 * Helper class to hold hashed string and salt generation. 200 */ 201 public class HashResult { 202 public String hashedString; 203 public int saltGeneration; 204 HashResult(String hString, int saltGen)205 public HashResult(String hString, int saltGen) { 206 hashedString = hString; 207 saltGeneration = saltGen; 208 } 209 } 210 } 211