Add an encrypted key-value store.

SignalStore is backed by SQLCipher and is intended to be used instead of
TextSecurePreferences moving forward.
This commit is contained in:
Greyson Parrelli
2020-01-10 01:08:39 -05:00
parent 711d22a0ed
commit 4b5b9fbde8
13 changed files with 791 additions and 25 deletions

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
public class KeyValueDataSet implements KeyValueReader {
private final Map<String, Object> values = new HashMap<>();
private final Map<String, Class> types = new HashMap<>();
public void putBlob(@NonNull String key, byte[] value) {
values.put(key, value);
types.put(key, byte[].class);
}
public void putBoolean(@NonNull String key, boolean value) {
values.put(key, value);
types.put(key, Boolean.class);
}
public void putFloat(@NonNull String key, float value) {
values.put(key, value);
types.put(key, Float.class);
}
public void putInteger(@NonNull String key, int value) {
values.put(key, value);
types.put(key, Integer.class);
}
public void putLong(@NonNull String key, long value) {
values.put(key, value);
types.put(key, Long.class);
}
public void putString(@NonNull String key, String value) {
values.put(key, value);
types.put(key, String.class);
}
void putAll(@NonNull KeyValueDataSet other) {
values.putAll(other.values);
types.putAll(other.types);
}
void removeAll(@NonNull Collection<String> removes) {
for (String remove : removes) {
values.remove(remove);
types.remove(remove);
}
}
@Override
public byte[] getBlob(@NonNull String key, byte[] defaultValue) {
if (containsKey(key)) {
return readValueAsType(key, byte[].class, true);
} else {
return defaultValue;
}
}
@Override
public boolean getBoolean(@NonNull String key, boolean defaultValue) {
if (containsKey(key)) {
return readValueAsType(key, Boolean.class, false);
} else {
return defaultValue;
}
}
@Override
public float getFloat(@NonNull String key, float defaultValue) {
if (containsKey(key)) {
return readValueAsType(key, Float.class, false);
} else {
return defaultValue;
}
}
@Override
public int getInteger(@NonNull String key, int defaultValue) {
if (containsKey(key)) {
return readValueAsType(key, Integer.class, false);
} else {
return defaultValue;
}
}
@Override
public long getLong(@NonNull String key, long defaultValue) {
if (containsKey(key)) {
return readValueAsType(key, Long.class, false);
} else {
return defaultValue;
}
}
@Override
public String getString(@NonNull String key, String defaultValue) {
if (containsKey(key)) {
return readValueAsType(key, String.class, true);
} else {
return defaultValue;
}
}
public @NonNull Map<String, Object> getValues() {
return values;
}
public Class getType(@NonNull String key) {
return types.get(key);
}
public boolean containsKey(@NonNull String key) {
return values.containsKey(key);
}
private <E> E readValueAsType(@NonNull String key, Class<E> type, boolean nullable) {
Object value = values.get(key);
if ((value == null && nullable) || (value != null && value.getClass() == type)) {
return type.cast(value);
} else {
throw new IllegalArgumentException("Type mismatch!");
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
interface KeyValueReader {
byte[] getBlob(@NonNull String key, byte[] defaultValue);
boolean getBoolean(@NonNull String key, boolean defaultValue);
float getFloat(@NonNull String key, float defaultValue);
int getInteger(@NonNull String key, int defaultValue);
long getLong(@NonNull String key, long defaultValue);
String getString(@NonNull String key, String defaultValue);
}

View File

@@ -0,0 +1,199 @@
package org.thoughtcrime.securesms.keyvalue;
import android.content.Context;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
/**
* An replacement for {@link android.content.SharedPreferences} that stores key-value pairs in our
* encrypted database.
*
* Implemented as a write-through cache that is safe to read and write to on the main thread.
*
* Writes are enqueued on a separate executor, but writes are finished up in
* {@link SignalUncaughtExceptionHandler}, meaning all write should finish barring a native crash
* or the system killing us unexpectedly (i.e. a force-stop).
*/
public final class KeyValueStore implements KeyValueReader {
private static final String TAG = Log.tag(KeyValueStore.class);
private final ExecutorService executor;
private final KeyValueDatabase database;
private KeyValueDataSet dataSet;
public KeyValueStore(@NonNull Context context) {
this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-KeyValueStore");
this.database = DatabaseFactory.getKeyValueDatabase(context);
}
@AnyThread
@Override
public synchronized byte[] getBlob(@NonNull String key, byte[] defaultValue) {
initializeIfNecessary();
return dataSet.getBlob(key, defaultValue);
}
@AnyThread
@Override
public synchronized boolean getBoolean(@NonNull String key, boolean defaultValue) {
initializeIfNecessary();
return dataSet.getBoolean(key, defaultValue);
}
@AnyThread
@Override
public synchronized float getFloat(@NonNull String key, float defaultValue) {
initializeIfNecessary();
return dataSet.getFloat(key, defaultValue);
}
@AnyThread
@Override
public synchronized int getInteger(@NonNull String key, int defaultValue) {
initializeIfNecessary();
return dataSet.getInteger(key, defaultValue);
}
@AnyThread
@Override
public synchronized long getLong(@NonNull String key, long defaultValue) {
initializeIfNecessary();
return dataSet.getLong(key, defaultValue);
}
@AnyThread
@Override
public synchronized String getString(@NonNull String key, String defaultValue) {
initializeIfNecessary();
return dataSet.getString(key, defaultValue);
}
/**
* @return A writer that allows writing and removing multiple entries in a single atomic
* transaction.
*/
@AnyThread
@NonNull Writer beginWrite() {
return new Writer();
}
/**
* @return A reader that lets you read from an immutable snapshot of the store, ensuring that data
* is consistent between reads. If you're only reading a single value, it is more
* efficient to use the various get* methods instead.
*/
@AnyThread
synchronized @NonNull KeyValueReader beginRead() {
initializeIfNecessary();
KeyValueDataSet copy = new KeyValueDataSet();
copy.putAll(dataSet);
return copy;
}
/**
* Ensures that any pending writes (such as those made via {@link Writer#apply()}) are finished.
*/
@AnyThread
synchronized void blockUntilAllWritesFinished() {
CountDownLatch latch = new CountDownLatch(1);
executor.execute(latch::countDown);
try {
latch.await();
} catch (InterruptedException e) {
Log.w(TAG, "Failed to wait for all writes.");
}
}
private synchronized void write(@NonNull KeyValueDataSet newDataSet, @NonNull Collection<String> removes) {
initializeIfNecessary();
dataSet.putAll(newDataSet);
dataSet.removeAll(removes);
executor.execute(() -> database.writeDataSet(newDataSet, removes));
}
private void initializeIfNecessary() {
if (dataSet != null) return;
this.dataSet = database.getDataSet();
}
class Writer {
private final KeyValueDataSet dataSet = new KeyValueDataSet();
private final Set<String> removes = new HashSet<>();
@NonNull Writer putBlob(@NonNull String key, @Nullable byte[] value) {
dataSet.putBlob(key, value);
return this;
}
@NonNull Writer putBoolean(@NonNull String key, boolean value) {
dataSet.putBoolean(key, value);
return this;
}
@NonNull Writer putFloat(@NonNull String key, float value) {
dataSet.putFloat(key, value);
return this;
}
@NonNull Writer putInteger(@NonNull String key, int value) {
dataSet.putInteger(key, value);
return this;
}
@NonNull Writer putLong(@NonNull String key, long value) {
dataSet.putLong(key, value);
return this;
}
@NonNull Writer putString(@NonNull String key, String value) {
dataSet.putString(key, value);
return this;
}
@NonNull Writer remove(@NonNull String key) {
removes.add(key);
return this;
}
@AnyThread
void apply() {
for (String key : removes) {
if (dataSet.containsKey(key)) {
throw new IllegalStateException("Tried to remove a key while also setting it!");
}
}
write(dataSet, removes);
}
@WorkerThread
void commit() {
apply();
blockUntilAllWritesFinished();
}
}
}

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
/**
* Simple, encrypted key-value store.
*/
public final class SignalStore {
private SignalStore() {}
/**
* Ensures any pending writes are finished. Only intended to be called by
* {@link SignalUncaughtExceptionHandler}.
*/
public static void blockUntilAllWritesFinished() {
getStore().blockUntilAllWritesFinished();
}
private static @NonNull KeyValueStore getStore() {
return ApplicationDependencies.getKeyValueStore();
}
private static void putBlob(@NonNull String key, byte[] value) {
getStore().beginWrite().putBlob(key, value).apply();
}
private static void putBoolean(@NonNull String key, boolean value) {
getStore().beginWrite().putBoolean(key, value).apply();
}
private static void putFloat(@NonNull String key, float value) {
getStore().beginWrite().putFloat(key, value).apply();
}
private static void putInteger(@NonNull String key, int value) {
getStore().beginWrite().putInteger(key, value).apply();
}
private static void putLong(@NonNull String key, long value) {
getStore().beginWrite().putLong(key, value).apply();
}
private static void putString(@NonNull String key, String value) {
getStore().beginWrite().putString(key, value).apply();
}
}