blob: eabaae540c123433cf765faf90695626b5b78c31 [file] [log] [blame]
/*
* Copyright 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 androidx.security.crypto;
import static androidx.security.crypto.MasterKey.KEYSTORE_PATH_URI;
import static java.nio.charset.StandardCharsets.UTF_8;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.collection.ArraySet;
import com.google.crypto.tink.Aead;
import com.google.crypto.tink.DeterministicAead;
import com.google.crypto.tink.KeyTemplate;
import com.google.crypto.tink.KeyTemplates;
import com.google.crypto.tink.KeysetHandle;
import com.google.crypto.tink.aead.AeadConfig;
import com.google.crypto.tink.daead.DeterministicAeadConfig;
import com.google.crypto.tink.integration.android.AndroidKeysetManager;
import com.google.crypto.tink.subtle.Base64;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* An implementation of {@link SharedPreferences} that encrypts keys and values.
* <br />
* <br />
* <b>WARNING</b>: The preference file should not be backed up with Auto Backup. When restoring the
* file it is likely the key used to encrypt it will no longer be present. You should exclude all
* <code>EncryptedSharedPreference</code>s from backup using
* <a href="https://developer.android.com/guide/topics/data/autobackup#IncludingFiles">backup rules</a>.
* <br />
* <br />
* Basic use of the class:
*
* <pre>
* MasterKey masterKey = new MasterKey.Builder(context)
* .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
* .build();
*
* SharedPreferences sharedPreferences = EncryptedSharedPreferences.create(
* context,
* "secret_shared_prefs",
* masterKey,
* EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
* EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
* );
*
* // use the shared preferences and editor as you normally would
* SharedPreferences.Editor editor = sharedPreferences.edit();
* </pre>
*/
public final class EncryptedSharedPreferences implements SharedPreferences {
private static final String KEY_KEYSET_ALIAS =
"__androidx_security_crypto_encrypted_prefs_key_keyset__";
private static final String VALUE_KEYSET_ALIAS =
"__androidx_security_crypto_encrypted_prefs_value_keyset__";
private static final String NULL_VALUE = "__NULL__";
final SharedPreferences mSharedPreferences;
final CopyOnWriteArrayList<OnSharedPreferenceChangeListener> mListeners;
final String mFileName;
final String mMasterKeyAlias;
final Aead mValueAead;
final DeterministicAead mKeyDeterministicAead;
EncryptedSharedPreferences(@NonNull String name,
@NonNull String masterKeyAlias,
@NonNull SharedPreferences sharedPreferences,
@NonNull Aead aead,
@NonNull DeterministicAead deterministicAead) {
mFileName = name;
mSharedPreferences = sharedPreferences;
mMasterKeyAlias = masterKeyAlias;
mValueAead = aead;
mKeyDeterministicAead = deterministicAead;
mListeners = new CopyOnWriteArrayList<>();
}
/**
* Opens an instance of encrypted SharedPreferences
*
* @param fileName The name of the file to open; can not contain path
* separators.
* @param masterKey The master key to use.
* @param prefKeyEncryptionScheme The scheme to use for encrypting keys.
* @param prefValueEncryptionScheme The scheme to use for encrypting values.
* @return The SharedPreferences instance that encrypts all data.
* @throws GeneralSecurityException when a bad master key or keyset has been attempted
* @throws IOException when fileName can not be used
*/
@NonNull
public static SharedPreferences create(@NonNull Context context,
@NonNull String fileName,
@NonNull MasterKey masterKey,
@NonNull PrefKeyEncryptionScheme prefKeyEncryptionScheme,
@NonNull PrefValueEncryptionScheme prefValueEncryptionScheme)
throws GeneralSecurityException, IOException {
return create(fileName, masterKey.getKeyAlias(), context,
prefKeyEncryptionScheme, prefValueEncryptionScheme);
}
/**
* Opens an instance of encrypted SharedPreferences
*
* <p>If the <code>masterKeyAlias</code> used here is for a key that is not yet created, this
* method will not be thread safe. Use the alternate signature that is not deprecated for
* multi-threaded contexts.
*
* @deprecated Use {@link #create(Context, String, MasterKey,
* PrefKeyEncryptionScheme, PrefValueEncryptionScheme)} instead.
* @param fileName The name of the file to open; can not contain path
* separators.
* @param masterKeyAlias The alias of the master key to use.
* @param context The context to use to open the preferences file.
* @param prefKeyEncryptionScheme The scheme to use for encrypting keys.
* @param prefValueEncryptionScheme The scheme to use for encrypting values.
* @return The SharedPreferences instance that encrypts all data.
* @throws GeneralSecurityException when a bad master key or keyset has been attempted
* @throws IOException when fileName can not be used
*/
@Deprecated
@NonNull
public static SharedPreferences create(@NonNull String fileName,
@NonNull String masterKeyAlias,
@NonNull Context context,
@NonNull PrefKeyEncryptionScheme prefKeyEncryptionScheme,
@NonNull PrefValueEncryptionScheme prefValueEncryptionScheme)
throws GeneralSecurityException, IOException {
DeterministicAeadConfig.register();
AeadConfig.register();
final Context applicationContext = context.getApplicationContext();
KeysetHandle daeadKeysetHandle = new AndroidKeysetManager.Builder()
.withKeyTemplate(prefKeyEncryptionScheme.getKeyTemplate())
.withSharedPref(applicationContext, KEY_KEYSET_ALIAS, fileName)
.withMasterKeyUri(KEYSTORE_PATH_URI + masterKeyAlias)
.build().getKeysetHandle();
KeysetHandle aeadKeysetHandle = new AndroidKeysetManager.Builder()
.withKeyTemplate(prefValueEncryptionScheme.getKeyTemplate())
.withSharedPref(applicationContext, VALUE_KEYSET_ALIAS, fileName)
.withMasterKeyUri(KEYSTORE_PATH_URI + masterKeyAlias)
.build().getKeysetHandle();
DeterministicAead daead = daeadKeysetHandle.getPrimitive(DeterministicAead.class);
Aead aead = aeadKeysetHandle.getPrimitive(Aead.class);
return new EncryptedSharedPreferences(fileName, masterKeyAlias,
applicationContext.getSharedPreferences(fileName, Context.MODE_PRIVATE), aead,
daead);
}
/**
* The encryption scheme to encrypt keys.
*/
public enum PrefKeyEncryptionScheme {
/**
* Pref keys are encrypted deterministically with AES256-SIV-CMAC (RFC 5297).
*
* <p>For more information please see the Tink documentation:
*
* <p><a href="https://google.github.io/tink/javadoc/tink/1.7.0/com/google/crypto/tink/daead/AesSivKeyManager.html">AesSivKeyManager</a>.aes256SivTemplate()
*/
AES256_SIV("AES256_SIV");
private final String mDeterministicAeadKeyTemplateName;
PrefKeyEncryptionScheme(String deterministicAeadKeyTemplateName) {
mDeterministicAeadKeyTemplateName = deterministicAeadKeyTemplateName;
}
KeyTemplate getKeyTemplate() throws GeneralSecurityException {
return KeyTemplates.get(mDeterministicAeadKeyTemplateName);
}
}
/**
* The encryption scheme to encrypt values.
*/
public enum PrefValueEncryptionScheme {
/**
* Pref values are encrypted with AES256-GCM. The associated data is the encrypted pref key.
*
* <p>For more information please see the Tink documentation:
*
* <p><a href="https://google.github.io/tink/javadoc/tink/1.7.0/com/google/crypto/tink/aead/AesGcmKeyManager.html">AesGcmKeyManager</a>.aes256GcmTemplate()
*/
AES256_GCM("AES256_GCM");
private final String mAeadKeyTemplateName;
PrefValueEncryptionScheme(String aeadKeyTemplateName) {
mAeadKeyTemplateName = aeadKeyTemplateName;
}
KeyTemplate getKeyTemplate() throws GeneralSecurityException {
return KeyTemplates.get(mAeadKeyTemplateName);
}
}
private static final class Editor implements SharedPreferences.Editor {
private final EncryptedSharedPreferences mEncryptedSharedPreferences;
private final SharedPreferences.Editor mEditor;
private final List<String> mKeysChanged;
private final AtomicBoolean mClearRequested = new AtomicBoolean(false);
Editor(EncryptedSharedPreferences encryptedSharedPreferences,
SharedPreferences.Editor editor) {
mEncryptedSharedPreferences = encryptedSharedPreferences;
mEditor = editor;
mKeysChanged = new CopyOnWriteArrayList<>();
}
@Override
@NonNull
public SharedPreferences.Editor putString(@Nullable String key, @Nullable String value) {
if (value == null) {
value = NULL_VALUE;
}
byte[] stringBytes = value.getBytes(UTF_8);
int stringByteLength = stringBytes.length;
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Integer.BYTES
+ stringByteLength);
buffer.putInt(EncryptedType.STRING.getId());
buffer.putInt(stringByteLength);
buffer.put(stringBytes);
putEncryptedObject(key, buffer.array());
return this;
}
@Override
@NonNull
public SharedPreferences.Editor putStringSet(@Nullable String key,
@Nullable Set<String> values) {
if (values == null) {
values = new ArraySet<>();
values.add(NULL_VALUE);
}
List<byte[]> byteValues = new ArrayList<>(values.size());
int totalBytes = values.size() * Integer.BYTES;
for (String strValue : values) {
byte[] byteValue = strValue.getBytes(UTF_8);
byteValues.add(byteValue);
totalBytes += byteValue.length;
}
totalBytes += Integer.BYTES;
ByteBuffer buffer = ByteBuffer.allocate(totalBytes);
buffer.putInt(EncryptedType.STRING_SET.getId());
for (byte[] bytes : byteValues) {
buffer.putInt(bytes.length);
buffer.put(bytes);
}
putEncryptedObject(key, buffer.array());
return this;
}
@Override
@NonNull
public SharedPreferences.Editor putInt(@Nullable String key, int value) {
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Integer.BYTES);
buffer.putInt(EncryptedType.INT.getId());
buffer.putInt(value);
putEncryptedObject(key, buffer.array());
return this;
}
@Override
@NonNull
public SharedPreferences.Editor putLong(@Nullable String key, long value) {
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Long.BYTES);
buffer.putInt(EncryptedType.LONG.getId());
buffer.putLong(value);
putEncryptedObject(key, buffer.array());
return this;
}
@Override
@NonNull
public SharedPreferences.Editor putFloat(@Nullable String key, float value) {
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Float.BYTES);
buffer.putInt(EncryptedType.FLOAT.getId());
buffer.putFloat(value);
putEncryptedObject(key, buffer.array());
return this;
}
@Override
@NonNull
public SharedPreferences.Editor putBoolean(@Nullable String key, boolean value) {
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + Byte.BYTES);
buffer.putInt(EncryptedType.BOOLEAN.getId());
buffer.put(value ? (byte) 1 : (byte) 0);
putEncryptedObject(key, buffer.array());
return this;
}
@Override
@NonNull
public SharedPreferences.Editor remove(@Nullable String key) {
if (mEncryptedSharedPreferences.isReservedKey(key)) {
throw new SecurityException(key + " is a reserved key for the encryption keyset.");
}
mEditor.remove(mEncryptedSharedPreferences.encryptKey(key));
mKeysChanged.add(key);
return this;
}
@Override
@NonNull
public SharedPreferences.Editor clear() {
// Set the flag to clear on commit, this operation happens first on commit.
// Cannot use underlying clear operation, it will remove the keysets and
// break the editor.
mClearRequested.set(true);
return this;
}
@Override
public boolean commit() {
clearKeysIfNeeded();
try {
return mEditor.commit();
} finally {
notifyListeners();
mKeysChanged.clear();
}
}
@Override
public void apply() {
clearKeysIfNeeded();
mEditor.apply();
notifyListeners();
mKeysChanged.clear();
}
private void clearKeysIfNeeded() {
// Call "clear" first as per the documentation, remove all keys that haven't
// been modified in this editor.
if (mClearRequested.getAndSet(false)) {
for (String key : mEncryptedSharedPreferences.getAll().keySet()) {
if (!mKeysChanged.contains(key)
&& !mEncryptedSharedPreferences.isReservedKey(key)) {
mEditor.remove(mEncryptedSharedPreferences.encryptKey(key));
}
}
}
}
private void putEncryptedObject(String key, byte[] value) {
if (mEncryptedSharedPreferences.isReservedKey(key)) {
throw new SecurityException(key + " is a reserved key for the encryption keyset.");
}
mKeysChanged.add(key);
if (key == null) {
key = NULL_VALUE;
}
try {
Pair<String, String> encryptedPair = mEncryptedSharedPreferences
.encryptKeyValuePair(key, value);
mEditor.putString(encryptedPair.first, encryptedPair.second);
} catch (GeneralSecurityException ex) {
throw new SecurityException("Could not encrypt data: " + ex.getMessage(), ex);
}
}
private void notifyListeners() {
for (OnSharedPreferenceChangeListener listener :
mEncryptedSharedPreferences.mListeners) {
for (String key : mKeysChanged) {
listener.onSharedPreferenceChanged(mEncryptedSharedPreferences, key);
}
}
}
}
// SharedPreferences methods
@Override
@NonNull
public Map<String, ?> getAll() {
Map<String, ? super Object> allEntries = new HashMap<>();
for (Map.Entry<String, ?> entry : mSharedPreferences.getAll().entrySet()) {
if (!isReservedKey(entry.getKey())) {
String decryptedKey = decryptKey(entry.getKey());
allEntries.put(decryptedKey,
getDecryptedObject(decryptedKey));
}
}
return allEntries;
}
@Nullable
@Override
public String getString(@Nullable String key, @Nullable String defValue) {
Object value = getDecryptedObject(key);
return (value instanceof String ? (String) value : defValue);
}
@SuppressWarnings("unchecked")
@Nullable
@Override
public Set<String> getStringSet(@Nullable String key, @Nullable Set<String> defValues) {
Set<String> returnValues;
Object value = getDecryptedObject(key);
if (value instanceof Set) {
returnValues = (Set<String>) value;
} else {
returnValues = new ArraySet<>();
}
return returnValues.size() > 0 ? returnValues : defValues;
}
@Override
public int getInt(@Nullable String key, int defValue) {
Object value = getDecryptedObject(key);
return (value instanceof Integer ? (Integer) value : defValue);
}
@Override
public long getLong(@Nullable String key, long defValue) {
Object value = getDecryptedObject(key);
return (value instanceof Long ? (Long) value : defValue);
}
@Override
public float getFloat(@Nullable String key, float defValue) {
Object value = getDecryptedObject(key);
return (value instanceof Float ? (Float) value : defValue);
}
@Override
public boolean getBoolean(@Nullable String key, boolean defValue) {
Object value = getDecryptedObject(key);
return (value instanceof Boolean ? (Boolean) value : defValue);
}
@Override
public boolean contains(@Nullable String key) {
if (isReservedKey(key)) {
throw new SecurityException(key + " is a reserved key for the encryption keyset.");
}
String encryptedKey = encryptKey(key);
return mSharedPreferences.contains(encryptedKey);
}
@Override
@NonNull
public SharedPreferences.Editor edit() {
return new Editor(this, mSharedPreferences.edit());
}
@Override
public void registerOnSharedPreferenceChangeListener(
@NonNull OnSharedPreferenceChangeListener listener) {
mListeners.add(listener);
}
@Override
public void unregisterOnSharedPreferenceChangeListener(
@NonNull OnSharedPreferenceChangeListener listener) {
mListeners.remove(listener);
}
/**
* Internal enum to set the type of encrypted data.
*/
private enum EncryptedType {
STRING(0),
STRING_SET(1),
INT(2),
LONG(3),
FLOAT(4),
BOOLEAN(5);
private final int mId;
EncryptedType(int id) {
mId = id;
}
public int getId() {
return mId;
}
@Nullable
public static EncryptedType fromId(int id) {
switch (id) {
case 0:
return STRING;
case 1:
return STRING_SET;
case 2:
return INT;
case 3:
return LONG;
case 4:
return FLOAT;
case 5:
return BOOLEAN;
}
return null;
}
}
private Object getDecryptedObject(String key) throws SecurityException {
if (isReservedKey(key)) {
throw new SecurityException(key + " is a reserved key for the encryption keyset.");
}
if (key == null) {
key = NULL_VALUE;
}
try {
String encryptedKey = encryptKey(key);
String encryptedValue = mSharedPreferences.getString(encryptedKey, null);
if (encryptedValue == null) {
return null;
}
byte[] cipherText = Base64.decode(encryptedValue, Base64.DEFAULT);
byte[] value = mValueAead.decrypt(cipherText, encryptedKey.getBytes(UTF_8));
ByteBuffer buffer = ByteBuffer.wrap(value);
buffer.position(0);
int typeId = buffer.getInt();
EncryptedType type = EncryptedType.fromId(typeId);
if (type == null) {
throw new SecurityException("Unknown type ID for encrypted pref value: " + typeId);
}
switch (type) {
case STRING:
int stringLength = buffer.getInt();
ByteBuffer stringSlice = buffer.slice();
buffer.limit(stringLength);
String stringValue = UTF_8.decode(stringSlice).toString();
if (stringValue.equals(NULL_VALUE)) {
return null;
}
return stringValue;
case INT:
return buffer.getInt();
case LONG:
return buffer.getLong();
case FLOAT:
return buffer.getFloat();
case BOOLEAN:
return buffer.get() != (byte) 0;
case STRING_SET:
ArraySet<String> stringSet = new ArraySet<>();
while (buffer.hasRemaining()) {
int subStringLength = buffer.getInt();
ByteBuffer subStringSlice = buffer.slice();
subStringSlice.limit(subStringLength);
buffer.position(buffer.position() + subStringLength);
stringSet.add(UTF_8.decode(subStringSlice).toString());
}
if (stringSet.size() == 1 && NULL_VALUE.equals(stringSet.valueAt(0))) {
return null;
}
return stringSet;
default:
throw new SecurityException("Unhandled type for encrypted pref value: " + type);
}
} catch (GeneralSecurityException ex) {
throw new SecurityException("Could not decrypt value. " + ex.getMessage(), ex);
}
}
String encryptKey(String key) {
if (key == null) {
key = NULL_VALUE;
}
try {
byte[] encryptedKeyBytes = mKeyDeterministicAead.encryptDeterministically(
key.getBytes(UTF_8),
mFileName.getBytes());
return Base64.encode(encryptedKeyBytes);
} catch (GeneralSecurityException ex) {
throw new SecurityException("Could not encrypt key. " + ex.getMessage(), ex);
}
}
String decryptKey(String encryptedKey) {
try {
byte[] clearText = mKeyDeterministicAead.decryptDeterministically(
Base64.decode(encryptedKey, Base64.DEFAULT),
mFileName.getBytes());
String key = new String(clearText, UTF_8);
if (key.equals(NULL_VALUE)) {
key = null;
}
return key;
} catch (GeneralSecurityException ex) {
throw new SecurityException("Could not decrypt key. " + ex.getMessage(), ex);
}
}
/**
* Check usage of the key and value keysets.
*
* @param key the plain text key
*/
boolean isReservedKey(String key) {
return KEY_KEYSET_ALIAS.equals(key) || VALUE_KEYSET_ALIAS.equals(key);
}
Pair<String, String> encryptKeyValuePair(String key, byte[] value)
throws GeneralSecurityException {
String encryptedKey = encryptKey(key);
byte[] cipherText = mValueAead.encrypt(value, encryptedKey.getBytes(UTF_8));
return new Pair<>(encryptedKey, Base64.encode(cipherText));
}
}