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