1 /* 2 * Copyright (C) 2022 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 com.android.car.bluetooth; 18 19 import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.DUMP_INFO; 20 21 import android.annotation.NonNull; 22 import android.car.builtin.util.Slogf; 23 import android.content.Context; 24 import android.content.SharedPreferences; 25 import android.os.UserManager; 26 import android.util.Log; 27 28 import com.android.car.CarLog; 29 import com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport; 30 import com.android.car.internal.util.IndentingPrintWriter; 31 32 import java.math.BigInteger; 33 import java.util.ArrayList; 34 import java.util.Arrays; 35 import java.util.LinkedHashSet; 36 import java.util.List; 37 import java.util.Objects; 38 39 import javax.crypto.spec.SecretKeySpec; 40 41 /** 42 * A collection of 128-bit Account Keys that are received over the Fast Pair protocol. 43 * 44 * The specification requires that we store at least 5 Account Keys, but places no upper bound on 45 * how many we can store. It only mentions that they all must fit in our chosen packet size. To 46 * support this, we have a variable fixed upper bound of the number of stored keys. If you input a 47 * number less than five, it will be adjusted up. 48 * 49 * The specification also requires that we remove the least recently used key if we ever run out of 50 * space. To support this, keys are stored in an LRU cache. Adding a key when storage is full will 51 * automatically remove the least recently used key. 52 * 53 * The specification requires that keys are persisted. To support this, keys are written to the 54 * user's Shared Preferences. There is one preferences for the count of keys, and then a preference 55 * for each key in priority order, where an index preference is mapped to a key value, i.e. the 56 * perference "0" would map to a 128-bit key. 57 * 58 * Keys are loaded from Shared Preferences upon creation of this object. 59 */ 60 public class FastPairAccountKeyStorage { 61 private static final String TAG = CarLog.tagFor(FastPairAccountKeyStorage.class); 62 private static final boolean DBG = Slogf.isLoggable(TAG, Log.DEBUG); 63 64 private static final String FAST_PAIR_PREFERENCES = "com.android.car.bluetooth"; 65 private static final String NUM_ACCOUNT_KEYS = "AccountKeysCount"; 66 67 private final Context mContext; 68 69 /** 70 * Represents a 128-bit Account Key that can be received through the FastPair process. 71 */ 72 public static class AccountKey { 73 private final byte[] mKey; 74 AccountKey(byte[] key)75 AccountKey(byte[] key) { 76 mKey = key; 77 } 78 AccountKey(String key)79 AccountKey(String key) { 80 mKey = new BigInteger(key).toByteArray(); 81 } 82 83 /** 84 * Get a byte representation of this Account Key 85 */ toBytes()86 public byte[] toBytes() { 87 return mKey; 88 } 89 90 /** 91 * Get a SecretKeySpec representation of this Account Key 92 */ getKeySpec()93 public SecretKeySpec getKeySpec() { 94 return new SecretKeySpec(mKey, "AES"); 95 } 96 97 @Override hashCode()98 public int hashCode() { 99 return Arrays.hashCode(mKey); 100 } 101 102 @Override equals(Object obj)103 public boolean equals(Object obj) { 104 if (!(obj instanceof AccountKey)) { 105 return false; 106 } 107 AccountKey other = (AccountKey) obj; 108 return other != null && Arrays.equals(toBytes(), other.toBytes()); 109 } 110 111 @Override toString()112 public String toString() { 113 return Arrays.toString(mKey); 114 } 115 } 116 117 /* 118 * A LinkedHashSet is used as an LRU. Iterating on the LinkedHashSet will produce the items in 119 * the order they were inserted-- first inserted, first iterated on. 120 */ 121 private final LinkedHashSet<AccountKey> mKeys; 122 private final Object mKeyLock = new Object(); 123 private final int mStorageSize; 124 FastPairAccountKeyStorage(Context context, int size)125 public FastPairAccountKeyStorage(Context context, int size) { 126 if (size < 5) { 127 throw new IllegalArgumentException("size < 5"); 128 } 129 mContext = Objects.requireNonNull(context); 130 mStorageSize = size; 131 mKeys = new LinkedHashSet<AccountKey>(mStorageSize); 132 load(); // A no-op if storage isn't unlocked yet 133 } 134 135 /** 136 * Get the total number of account keys that can be stored 137 */ capacity()138 public int capacity() { 139 return mStorageSize; 140 } 141 142 /** 143 * Add an account key 144 */ add(@onNull AccountKey key)145 public boolean add(@NonNull AccountKey key) { 146 if (key == null) return false; 147 Slogf.i("Adding key '%s'", key.toString()); 148 synchronized (mKeyLock) { 149 // LinkedHashSet re-adds do not impact the ordering. To force the ordering to update, 150 // we'll remove the key first if its already in the set, then re-add it. 151 if (mKeys.contains(key)) { 152 mKeys.remove(key); 153 } 154 mKeys.add(key); 155 trimToSize(); 156 commit(); 157 return true; 158 } 159 } 160 161 /** 162 * Remove an account key 163 */ remove(@onNull AccountKey key)164 public boolean remove(@NonNull AccountKey key) { 165 if (key == null) return false; 166 Slogf.i("Removing key '%s'", key.toString()); 167 synchronized (mKeyLock) { 168 mKeys.remove(key); 169 commit(); 170 return true; 171 } 172 } 173 174 /** 175 * Get a list of all the available account keys 176 */ getAllAccountKeys()177 public List<AccountKey> getAllAccountKeys() { 178 synchronized (mKeyLock) { 179 return new ArrayList<>(mKeys); 180 } 181 } 182 183 /** 184 * Clears all account keys from storage 185 */ clear()186 public void clear() { 187 synchronized (mKeyLock) { 188 mKeys.clear(); 189 commit(); 190 } 191 } 192 193 /** 194 * Removes the least recently used items until the size of our cache is less than or equal to 195 * our configured maximum size. 196 */ trimToSize()197 private void trimToSize() { 198 while (mKeys.size() > mStorageSize) { 199 AccountKey key = mKeys.iterator().next(); 200 mKeys.remove(key); 201 Slogf.d("Evicted key '%s'", key.toString()); 202 } 203 } 204 205 /** 206 * Loads persisted account keys from Shared Preferences 207 * 208 * Account keys are stored in key value pairs of <integer> to <string>, where the integer is the 209 * position in the LRU (higher is more recently used), and the string is a string version of the 210 * bytes. There is also an "AccountKeysCount" preference indicating how many keys are stored. 211 * Keys will have integer keys in the range [0, AccountKeysCount - 1]. 212 * 213 * This cannot be called until the user is unlocked. 214 */ load()215 public boolean load() { 216 if (!isUserUnlocked()) { 217 // TODO (243016325): Determine a way to recover from a failed load() 218 Slogf.w(TAG, "Loaded while user was not unlocked. Shared Preferences unavailable"); 219 return false; 220 } 221 222 List<AccountKey> keys = new ArrayList<>(); 223 SharedPreferences preferences = 224 mContext.getSharedPreferences(FAST_PAIR_PREFERENCES, Context.MODE_PRIVATE); 225 int numKeys = preferences.getInt(NUM_ACCOUNT_KEYS, 0); 226 227 for (int i = 0; i < numKeys; i++) { 228 String key = preferences.getString(Integer.toString(i), null); 229 if (key != null) { 230 keys.add(new AccountKey(key)); 231 } 232 } 233 Slogf.d(TAG, "Read %d/%d keys from SharedPreferences", keys.size(), numKeys); 234 235 synchronized (mKeyLock) { 236 mKeys.clear(); 237 for (AccountKey key : keys) { 238 mKeys.add(key); 239 } 240 trimToSize(); 241 commit(); 242 } 243 return true; 244 } 245 246 /** 247 * Persists the set of Account Keys to Shared Preferences. 248 * 249 * Account keys are stored in key value pairs of <integer> to <string>, where the integer is the 250 * position in the LRU (higher is more recently used), and the string is a string version of the 251 * bytes. There is also an "AccountKeysCount" preference indicating how many keys are stored. 252 * Keys will have integer keys in the range [0, AccountKeysCount - 1]. 253 */ commit()254 private boolean commit() { 255 if (!isUserUnlocked()) { 256 // TODO (243016325): Determine a way to recover from a failed commit() 257 Slogf.w(TAG, "Committed while user was not unlocked. Shared Preferences unavailable"); 258 return false; 259 } 260 261 SharedPreferences preferences = 262 mContext.getSharedPreferences(FAST_PAIR_PREFERENCES, Context.MODE_PRIVATE); 263 SharedPreferences.Editor editor = preferences.edit(); 264 265 // Get the current count of stored keys 266 int accountKeyCount = preferences.getInt(NUM_ACCOUNT_KEYS, 0); 267 int finalSize = mKeys.size(); 268 269 for (int i = accountKeyCount - 1; i >= finalSize; i--) { 270 editor.remove(Integer.toString(i)); 271 } 272 273 // Add the count of keys 274 editor.putInt(NUM_ACCOUNT_KEYS, finalSize); 275 276 // Add the keys themselves and apply 277 int i = 0; 278 for (AccountKey key : mKeys) { 279 editor.putString(Integer.toString(i), new BigInteger(key.toBytes()).toString()); 280 i++; 281 } 282 editor.apply(); 283 284 if (DBG) { 285 Slogf.d(TAG, "Committed keys to SharedPreferences, keys=%s", mKeys); 286 } 287 return true; 288 } 289 isUserUnlocked()290 private boolean isUserUnlocked() { 291 return mContext.getSystemService(UserManager.class).isUserUnlocked(); 292 } 293 294 @Override toString()295 public String toString() { 296 return "FastPairAccountKeyStorage (Size=" + mKeys.size() + " / " + mStorageSize + ")"; 297 } 298 299 @ExcludeFromCodeCoverageGeneratedReport(reason = DUMP_INFO) dump(IndentingPrintWriter writer)300 void dump(IndentingPrintWriter writer) { 301 writer.println(toString()); 302 writer.increaseIndent(); 303 List<AccountKey> keys = getAllAccountKeys(); 304 for (AccountKey key : keys) { 305 writer.println("\n" + key); 306 } 307 writer.decreaseIndent(); 308 } 309 } 310