/* * Copyright (C) 2019 The Android Open Source Project * * 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 android.util; import android.content.Context; import android.content.SharedPreferences; import android.os.Environment; import android.os.storage.StorageManager; import android.text.TextUtils; import com.android.internal.annotations.VisibleForTesting; import java.io.File; import java.nio.charset.Charset; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; /** * HashedStringCache provides hashing functionality with an underlying LRUCache and expiring salt. * Salt and expiration time are being stored under the tag passed in by the calling package -- * intended usage is the calling package name. * @hide */ public class HashedStringCache { private static HashedStringCache sHashedStringCache = null; private static final Charset UTF_8 = Charset.forName("UTF-8"); private static final int HASH_CACHE_SIZE = 100; private static final int HASH_LENGTH = 8; @VisibleForTesting static final String HASH_SALT = "_hash_salt"; @VisibleForTesting static final String HASH_SALT_DATE = "_hash_salt_date"; @VisibleForTesting static final String HASH_SALT_GEN = "_hash_salt_gen"; // For privacy we need to rotate the salt regularly private static final long DAYS_TO_MILLIS = 1000 * 60 * 60 * 24; private static final int MAX_SALT_DAYS = 100; private final LruCache mHashes; private final SecureRandom mSecureRandom; private final Object mPreferenceLock = new Object(); private final MessageDigest mDigester; private byte[] mSalt; private int mSaltGen; private SharedPreferences mSharedPreferences; private static final String TAG = "HashedStringCache"; private static final boolean DEBUG = false; private HashedStringCache() { mHashes = new LruCache<>(HASH_CACHE_SIZE); mSecureRandom = new SecureRandom(); try { mDigester = MessageDigest.getInstance("MD5"); } catch (NoSuchAlgorithmException impossible) { // this can't happen - MD5 is always present throw new RuntimeException(impossible); } } /** * @return - instance of the HashedStringCache * @hide */ public static HashedStringCache getInstance() { if (sHashedStringCache == null) { sHashedStringCache = new HashedStringCache(); } return sHashedStringCache; } /** * Take the string and context and create a hash of the string. Trigger refresh on salt if salt * is more than 7 days old * @param context - callers context to retrieve SharedPreferences * @param clearText - string that needs to be hashed * @param tag - class name to use for storing values in shared preferences * @param saltExpirationDays - number of days we may keep the same salt * special value -1 will short-circuit and always return null. * @return - HashResult containing the hashed string and the generation of the hash salt, null * if clearText string is empty * * @hide */ public HashResult hashString(Context context, String tag, String clearText, int saltExpirationDays) { if (saltExpirationDays == -1 || context == null || TextUtils.isEmpty(clearText) || TextUtils.isEmpty(tag)) { return null; } populateSaltValues(context, tag, saltExpirationDays); String hashText = mHashes.get(clearText); if (hashText != null) { return new HashResult(hashText, mSaltGen); } mDigester.reset(); mDigester.update(mSalt); mDigester.update(clearText.getBytes(UTF_8)); byte[] bytes = mDigester.digest(); int len = Math.min(HASH_LENGTH, bytes.length); hashText = Base64.encodeToString(bytes, 0, len, Base64.NO_PADDING | Base64.NO_WRAP); mHashes.put(clearText, hashText); return new HashResult(hashText, mSaltGen); } /** * Populates the mSharedPreferences and checks if there is a salt present and if it's older than * 7 days * @param tag - class name to use for storing values in shared preferences * @param saltExpirationDays - number of days we may keep the same salt * @param saltDate - the date retrieved from configuration * @return - true if no salt or salt is older than 7 days */ private boolean checkNeedsNewSalt(String tag, int saltExpirationDays, long saltDate) { if (saltDate == 0 || saltExpirationDays < -1) { return true; } if (saltExpirationDays > MAX_SALT_DAYS) { saltExpirationDays = MAX_SALT_DAYS; } long now = System.currentTimeMillis(); long delta = now - saltDate; // Check for delta < 0 to make sure we catch if someone puts their phone far in the // future and then goes back to normal time. return delta >= saltExpirationDays * DAYS_TO_MILLIS || delta < 0; } /** * Populate the salt and saltGen member variables if they aren't already set / need refreshing. * @param context - to get sharedPreferences * @param tag - class name to use for storing values in shared preferences * @param saltExpirationDays - number of days we may keep the same salt */ private void populateSaltValues(Context context, String tag, int saltExpirationDays) { synchronized (mPreferenceLock) { // check if we need to refresh the salt mSharedPreferences = getHashSharedPreferences(context); long saltDate = mSharedPreferences.getLong(tag + HASH_SALT_DATE, 0); boolean needsNewSalt = checkNeedsNewSalt(tag, saltExpirationDays, saltDate); if (needsNewSalt) { mHashes.evictAll(); } if (mSalt == null || needsNewSalt) { String saltString = mSharedPreferences.getString(tag + HASH_SALT, null); mSaltGen = mSharedPreferences.getInt(tag + HASH_SALT_GEN, 0); if (saltString == null || needsNewSalt) { mSaltGen++; byte[] saltBytes = new byte[16]; mSecureRandom.nextBytes(saltBytes); saltString = Base64.encodeToString(saltBytes, Base64.NO_PADDING | Base64.NO_WRAP); mSharedPreferences.edit() .putString(tag + HASH_SALT, saltString) .putInt(tag + HASH_SALT_GEN, mSaltGen) .putLong(tag + HASH_SALT_DATE, System.currentTimeMillis()).apply(); if (DEBUG) { Log.d(TAG, "created a new salt: " + saltString); } } mSalt = saltString.getBytes(UTF_8); } } } /** * Android:ui doesn't have persistent preferences, so need to fall back on this hack originally * from ChooserActivity.java * @param context * @return */ private SharedPreferences getHashSharedPreferences(Context context) { final File prefsFile = new File(new File( Environment.getDataUserCePackageDirectory( StorageManager.UUID_PRIVATE_INTERNAL, context.getUserId(), context.getPackageName()), "shared_prefs"), "hashed_cache.xml"); return context.getSharedPreferences(prefsFile, Context.MODE_PRIVATE); } /** * Helper class to hold hashed string and salt generation. */ public class HashResult { public String hashedString; public int saltGeneration; public HashResult(String hString, int saltGen) { hashedString = hString; saltGeneration = saltGen; } } }