OSDN Git Service

Implement startRecoverySession
authorRobert Berry <robertberry@google.com>
Wed, 20 Dec 2017 15:59:37 +0000 (15:59 +0000)
committerRobert Berry <robertberry@google.com>
Wed, 20 Dec 2017 21:24:38 +0000 (21:24 +0000)
Test: adb shell am instrument -w -e package com.android.server.locksettings.recoverablekeystore com.android.frameworks.servicestests/android.support.test.runner.AndroidJUnitRunner
Change-Id: I25e99f6014ef5e831420367040de7e1a80f134f0

core/java/android/security/recoverablekeystore/KeyDerivationParameters.java
services/core/java/com/android/server/locksettings/recoverablekeystore/KeySyncUtils.java
services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java
services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java [new file with mode: 0644]
services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java [new file with mode: 0644]
services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorageTest.java [new file with mode: 0644]

index 2205c41..978e60e 100644 (file)
@@ -60,7 +60,7 @@ public final class KeyDerivationParameters implements Parcelable {
     /**
      * Creates instance of the class to to derive key using salted SHA256 hash.
      */
-    public KeyDerivationParameters createSHA256Parameters(@NonNull byte[] salt) {
+    public static KeyDerivationParameters createSHA256Parameters(@NonNull byte[] salt) {
         return new KeyDerivationParameters(ALGORITHM_SHA256, salt);
     }
 
index 37aeb3a..25428e7 100644 (file)
@@ -20,10 +20,13 @@ import com.android.internal.annotations.VisibleForTesting;
 
 import java.nio.charset.StandardCharsets;
 import java.security.InvalidKeyException;
+import java.security.KeyFactory;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.security.PublicKey;
 import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -39,6 +42,7 @@ import javax.crypto.SecretKey;
  */
 public class KeySyncUtils {
 
+    private static final String PUBLIC_KEY_FACTORY_ALGORITHM = "EC";
     private static final String RECOVERY_KEY_ALGORITHM = "AES";
     private static final int RECOVERY_KEY_SIZE_BITS = 256;
 
@@ -237,6 +241,21 @@ public class KeySyncUtils {
     }
 
     /**
+     * Deserializes a X509 public key.
+     *
+     * @param key The bytes of the key.
+     * @return The key.
+     * @throws NoSuchAlgorithmException if the public key algorithm is unavailable.
+     * @throws InvalidKeySpecException if the bytes of the key are not a valid key.
+     */
+    public static PublicKey deserializePublicKey(byte[] key)
+            throws NoSuchAlgorithmException, InvalidKeySpecException {
+        KeyFactory keyFactory = KeyFactory.getInstance(PUBLIC_KEY_FACTORY_ALGORITHM);
+        X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(key);
+        return keyFactory.generatePublic(publicKeySpec);
+    }
+
+    /**
      * Returns the concatenation of all the given {@code arrays}.
      */
     @VisibleForTesting
index e459f28..74d132f 100644 (file)
@@ -30,7 +30,13 @@ import android.security.recoverablekeystore.KeyStoreRecoveryMetadata;
 import android.security.recoverablekeystore.RecoverableKeyStoreLoader;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.locksettings.recoverablekeystore.storage.RecoverableKeyStoreDb;
+import com.android.server.locksettings.recoverablekeystore.storage.RecoverySessionStorage;
 
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.spec.InvalidKeySpecException;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -44,7 +50,10 @@ public class RecoverableKeyStoreManager {
     private static final String TAG = "RecoverableKeyStoreManager";
 
     private static RecoverableKeyStoreManager mInstance;
-    private Context mContext;
+
+    private final Context mContext;
+    private final RecoverableKeyStoreDb mDatabase;
+    private final RecoverySessionStorage mRecoverySessionStorage;
 
     /**
      * Returns a new or existing instance.
@@ -53,14 +62,23 @@ public class RecoverableKeyStoreManager {
      */
     public static synchronized RecoverableKeyStoreManager getInstance(Context mContext) {
         if (mInstance == null) {
-            mInstance = new RecoverableKeyStoreManager(mContext);
+            RecoverableKeyStoreDb db = RecoverableKeyStoreDb.newInstance(mContext);
+            mInstance = new RecoverableKeyStoreManager(
+                    mContext.getApplicationContext(),
+                    db,
+                    new RecoverySessionStorage());
         }
         return mInstance;
     }
 
     @VisibleForTesting
-    RecoverableKeyStoreManager(Context context) {
+    RecoverableKeyStoreManager(
+            Context context,
+            RecoverableKeyStoreDb recoverableKeyStoreDb,
+            RecoverySessionStorage recoverySessionStorage) {
         mContext = context;
+        mDatabase = recoverableKeyStoreDb;
+        mRecoverySessionStorage = recoverySessionStorage;
     }
 
     public int initRecoveryService(
@@ -161,7 +179,13 @@ public class RecoverableKeyStoreManager {
     /**
      * Initializes recovery session.
      *
-     * @return recovery claim
+     * @param sessionId A unique ID to identify the recovery session.
+     * @param verifierPublicKey X509-encoded public key.
+     * @param vaultParams Additional params associated with vault.
+     * @param vaultChallenge Challenge issued by vault service.
+     * @param secrets Lock-screen hashes. Should have a single element. TODO: why is this a list?
+     * @return Encrypted bytes of recovery claim. This can then be issued to the vault service.
+     *
      * @hide
      */
     public byte[] startRecoverySession(
@@ -173,7 +197,40 @@ public class RecoverableKeyStoreManager {
             int userId)
             throws RemoteException {
         checkRecoverKeyStorePermission();
-        throw new UnsupportedOperationException();
+
+        if (secrets.size() != 1) {
+            // TODO: support multiple secrets
+            throw new RemoteException("Only a single KeyStoreRecoveryMetadata is supported");
+        }
+
+        byte[] keyClaimant = KeySyncUtils.generateKeyClaimant();
+        byte[] kfHash = secrets.get(0).getSecret();
+        mRecoverySessionStorage.add(
+                userId, new RecoverySessionStorage.Entry(sessionId, kfHash, keyClaimant));
+
+        try {
+            byte[] thmKfHash = KeySyncUtils.calculateThmKfHash(kfHash);
+            PublicKey publicKey = KeySyncUtils.deserializePublicKey(verifierPublicKey);
+            return KeySyncUtils.encryptRecoveryClaim(
+                    publicKey,
+                    vaultParams,
+                    vaultChallenge,
+                    thmKfHash,
+                    keyClaimant);
+        } catch (NoSuchAlgorithmException e) {
+            // Should never happen: all the algorithms used are required by AOSP implementations.
+            throw new RemoteException(
+                    "Missing required algorithm",
+                    e,
+                    /*enableSuppression=*/ true,
+                    /*writeableStackTrace=*/ true);
+        } catch (InvalidKeySpecException | InvalidKeyException e) {
+            throw new RemoteException(
+                    "Not a valid X509 key",
+                    e,
+                    /*enableSuppression=*/ true,
+                    /*writeableStackTrace=*/ true);
+        }
     }
 
     public void recoverKeys(
diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java
new file mode 100644 (file)
index 0000000..bc56ae1
--- /dev/null
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2017 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 com.android.server.locksettings.recoverablekeystore.storage;
+
+import android.annotation.Nullable;
+import android.util.SparseArray;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import javax.security.auth.Destroyable;
+
+/**
+ * Stores pending recovery sessions in memory. We do not write these to disk, as it contains hashes
+ * of the user's lock screen.
+ *
+ * @hide
+ */
+public class RecoverySessionStorage implements Destroyable {
+
+    private final SparseArray<ArrayList<Entry>> mSessionsByUid = new SparseArray<>();
+
+    /**
+     * Returns the session for the given user with the given id.
+     *
+     * @param uid The uid of the recovery agent who created the session.
+     * @param sessionId The unique identifier for the session.
+     * @return The session info.
+     *
+     * @hide
+     */
+    @Nullable
+    public Entry get(int uid, String sessionId) {
+        ArrayList<Entry> userEntries = mSessionsByUid.get(uid);
+        if (userEntries == null) {
+            return null;
+        }
+        for (Entry entry : userEntries) {
+            if (sessionId.equals(entry.mSessionId)) {
+                return entry;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Adds a pending session for the given user.
+     *
+     * @param uid The uid of the recovery agent who created the session.
+     * @param entry The session info.
+     *
+     * @hide
+     */
+    public void add(int uid, Entry entry) {
+        if (mSessionsByUid.get(uid) == null) {
+            mSessionsByUid.put(uid, new ArrayList<>());
+        }
+        mSessionsByUid.get(uid).add(entry);
+    }
+
+    /**
+     * Removes all sessions associated with the given recovery agent uid.
+     *
+     * @param uid The uid of the recovery agent whose sessions to remove.
+     *
+     * @hide
+     */
+    public void remove(int uid) {
+        ArrayList<Entry> entries = mSessionsByUid.get(uid);
+        if (entries == null) {
+            return;
+        }
+        for (Entry entry : entries) {
+            entry.destroy();
+        }
+        mSessionsByUid.remove(uid);
+    }
+
+    /**
+     * Returns the total count of pending sessions.
+     *
+     * @hide
+     */
+    public int size() {
+        int size = 0;
+        int numberOfUsers = mSessionsByUid.size();
+        for (int i = 0; i < numberOfUsers; i++) {
+            ArrayList<Entry> entries = mSessionsByUid.valueAt(i);
+            size += entries.size();
+        }
+        return size;
+    }
+
+    /**
+     * Wipes the memory of any sensitive information (i.e., lock screen hashes and key claimants).
+     *
+     * @hide
+     */
+    @Override
+    public void destroy() {
+        int numberOfUids = mSessionsByUid.size();
+        for (int i = 0; i < numberOfUids; i++) {
+            ArrayList<Entry> entries = mSessionsByUid.valueAt(i);
+            for (Entry entry : entries) {
+                entry.destroy();
+            }
+        }
+    }
+
+    /**
+     * Information about a recovery session.
+     *
+     * @hide
+     */
+    public static class Entry implements Destroyable {
+        private final byte[] mLskfHash;
+        private final byte[] mKeyClaimant;
+        private final String mSessionId;
+
+        /**
+         * @hide
+         */
+        public Entry(String sessionId, byte[] lskfHash, byte[] keyClaimant) {
+            this.mLskfHash = lskfHash;
+            this.mSessionId = sessionId;
+            this.mKeyClaimant = keyClaimant;
+        }
+
+        /**
+         * Returns the hash of the lock screen associated with the recovery attempt.
+         *
+         * @hide
+         */
+        public byte[] getLskfHash() {
+            return mLskfHash;
+        }
+
+        /**
+         * Returns the key generated for this recovery attempt (used to decrypt data returned by
+         * the server).
+         *
+         * @hide
+         */
+        public byte[] getKeyClaimant() {
+            return mKeyClaimant;
+        }
+
+        /**
+         * Overwrites the memory for the lskf hash and key claimant.
+         *
+         * @hide
+         */
+        @Override
+        public void destroy() {
+            Arrays.fill(mLskfHash, (byte) 0);
+            Arrays.fill(mKeyClaimant, (byte) 0);
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java
new file mode 100644 (file)
index 0000000..35b18b1
--- /dev/null
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2017 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 com.android.server.locksettings.recoverablekeystore;
+
+import static android.security.recoverablekeystore.KeyStoreRecoveryMetadata.TYPE_LOCKSCREEN;
+import static android.security.recoverablekeystore.KeyStoreRecoveryMetadata.TYPE_PASSWORD;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.os.RemoteException;
+import android.security.recoverablekeystore.KeyDerivationParameters;
+import android.security.recoverablekeystore.KeyStoreRecoveryMetadata;
+import android.security.recoverablekeystore.RecoverableKeyStoreLoader;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.server.locksettings.recoverablekeystore.storage.RecoverableKeyStoreDb;
+import com.android.server.locksettings.recoverablekeystore.storage.RecoverySessionStorage;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class RecoverableKeyStoreManagerTest {
+    private static final String DATABASE_FILE_NAME = "recoverablekeystore.db";
+
+    private static final String TEST_SESSION_ID = "karlin";
+    private static final byte[] TEST_PUBLIC_KEY = new byte[] {
+        (byte) 0x30, (byte) 0x59, (byte) 0x30, (byte) 0x13, (byte) 0x06, (byte) 0x07, (byte) 0x2a,
+        (byte) 0x86, (byte) 0x48, (byte) 0xce, (byte) 0x3d, (byte) 0x02, (byte) 0x01, (byte) 0x06,
+        (byte) 0x08, (byte) 0x2a, (byte) 0x86, (byte) 0x48, (byte) 0xce, (byte) 0x3d, (byte) 0x03,
+        (byte) 0x01, (byte) 0x07, (byte) 0x03, (byte) 0x42, (byte) 0x00, (byte) 0x04, (byte) 0xb8,
+        (byte) 0x00, (byte) 0x11, (byte) 0x18, (byte) 0x98, (byte) 0x1d, (byte) 0xf0, (byte) 0x6e,
+        (byte) 0xb4, (byte) 0x94, (byte) 0xfe, (byte) 0x86, (byte) 0xda, (byte) 0x1c, (byte) 0x07,
+        (byte) 0x8d, (byte) 0x01, (byte) 0xb4, (byte) 0x3a, (byte) 0xf6, (byte) 0x8d, (byte) 0xdc,
+        (byte) 0x61, (byte) 0xd0, (byte) 0x46, (byte) 0x49, (byte) 0x95, (byte) 0x0f, (byte) 0x10,
+        (byte) 0x86, (byte) 0x93, (byte) 0x24, (byte) 0x66, (byte) 0xe0, (byte) 0x3f, (byte) 0xd2,
+        (byte) 0xdf, (byte) 0xf3, (byte) 0x79, (byte) 0x20, (byte) 0x1d, (byte) 0x91, (byte) 0x55,
+        (byte) 0xb0, (byte) 0xe5, (byte) 0xbd, (byte) 0x7a, (byte) 0x8b, (byte) 0x32, (byte) 0x7d,
+        (byte) 0x25, (byte) 0x53, (byte) 0xa2, (byte) 0xfc, (byte) 0xa5, (byte) 0x65, (byte) 0xe1,
+        (byte) 0xbd, (byte) 0x21, (byte) 0x44, (byte) 0x7e, (byte) 0x78, (byte) 0x52, (byte) 0xfa};
+    private static final byte[] TEST_SALT = getUtf8Bytes("salt");
+    private static final byte[] TEST_SECRET = getUtf8Bytes("password1234");
+    private static final byte[] TEST_VAULT_CHALLENGE = getUtf8Bytes("vault_challenge");
+    private static final byte[] TEST_VAULT_PARAMS = getUtf8Bytes("vault_params");
+    private static final int TEST_USER_ID = 10009;
+    private static final int KEY_CLAIMANT_LENGTH_BYTES = 16;
+
+    @Mock private Context mMockContext;
+
+    private RecoverableKeyStoreDb mRecoverableKeyStoreDb;
+    private File mDatabaseFile;
+    private RecoverableKeyStoreManager mRecoverableKeyStoreManager;
+    private RecoverySessionStorage mRecoverySessionStorage;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        Context context = InstrumentationRegistry.getTargetContext();
+        mDatabaseFile = context.getDatabasePath(DATABASE_FILE_NAME);
+        mRecoverableKeyStoreDb = RecoverableKeyStoreDb.newInstance(context);
+        mRecoverySessionStorage = new RecoverySessionStorage();
+        mRecoverableKeyStoreManager = new RecoverableKeyStoreManager(
+                mMockContext,
+                mRecoverableKeyStoreDb,
+                mRecoverySessionStorage);
+    }
+
+    @After
+    public void tearDown() {
+        mRecoverableKeyStoreDb.close();
+        mDatabaseFile.delete();
+    }
+
+    @Test
+    public void startRecoverySession_checksPermissionFirst() throws Exception {
+        mRecoverableKeyStoreManager.startRecoverySession(
+                TEST_SESSION_ID,
+                TEST_PUBLIC_KEY,
+                TEST_VAULT_PARAMS,
+                TEST_VAULT_CHALLENGE,
+                ImmutableList.of(new KeyStoreRecoveryMetadata(
+                        TYPE_LOCKSCREEN,
+                        TYPE_PASSWORD,
+                        KeyDerivationParameters.createSHA256Parameters(TEST_SALT),
+                        TEST_SECRET)),
+                TEST_USER_ID);
+
+        verify(mMockContext, times(1)).enforceCallingOrSelfPermission(
+                eq(RecoverableKeyStoreLoader.PERMISSION_RECOVER_KEYSTORE),
+                any());
+    }
+
+    @Test
+    public void startRecoverySession_storesTheSessionInfo() throws Exception {
+        mRecoverableKeyStoreManager.startRecoverySession(
+                TEST_SESSION_ID,
+                TEST_PUBLIC_KEY,
+                TEST_VAULT_PARAMS,
+                TEST_VAULT_CHALLENGE,
+                ImmutableList.of(new KeyStoreRecoveryMetadata(
+                        TYPE_LOCKSCREEN,
+                        TYPE_PASSWORD,
+                        KeyDerivationParameters.createSHA256Parameters(TEST_SALT),
+                        TEST_SECRET)),
+                TEST_USER_ID);
+
+        assertEquals(1, mRecoverySessionStorage.size());
+        RecoverySessionStorage.Entry entry = mRecoverySessionStorage.get(
+                TEST_USER_ID, TEST_SESSION_ID);
+        assertArrayEquals(TEST_SECRET, entry.getLskfHash());
+        assertEquals(KEY_CLAIMANT_LENGTH_BYTES, entry.getKeyClaimant().length);
+    }
+
+    @Test
+    public void startRecoverySession_throwsIfBadNumberOfSecrets() throws Exception {
+        try {
+            mRecoverableKeyStoreManager.startRecoverySession(
+                    TEST_SESSION_ID,
+                    TEST_PUBLIC_KEY,
+                    TEST_VAULT_PARAMS,
+                    TEST_VAULT_CHALLENGE,
+                    ImmutableList.of(),
+                    TEST_USER_ID);
+        } catch (RemoteException e) {
+            assertEquals("Only a single KeyStoreRecoveryMetadata is supported",
+                    e.getMessage());
+        }
+    }
+
+    @Test
+    public void startRecoverySession_throwsIfBadKey() throws Exception {
+        try {
+            mRecoverableKeyStoreManager.startRecoverySession(
+                    TEST_SESSION_ID,
+                    getUtf8Bytes("0"),
+                    TEST_VAULT_PARAMS,
+                    TEST_VAULT_CHALLENGE,
+                    ImmutableList.of(new KeyStoreRecoveryMetadata(
+                            TYPE_LOCKSCREEN,
+                            TYPE_PASSWORD,
+                            KeyDerivationParameters.createSHA256Parameters(TEST_SALT),
+                            TEST_SECRET)),
+                    TEST_USER_ID);
+        } catch (RemoteException e) {
+            assertEquals("Not a valid X509 key",
+                    e.getMessage());
+        }
+    }
+
+    private static byte[] getUtf8Bytes(String s) {
+        return s.getBytes(StandardCharsets.UTF_8);
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorageTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorageTest.java
new file mode 100644 (file)
index 0000000..6aeff28
--- /dev/null
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2017 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 com.android.server.locksettings.recoverablekeystore.storage;
+
+import static junit.framework.Assert.fail;
+
+import static org.junit.Assert.assertEquals;
+
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class RecoverySessionStorageTest {
+
+    private static final String TEST_SESSION_ID = "peter";
+    private static final int TEST_USER_ID = 696;
+    private static final byte[] TEST_LSKF_HASH = getUtf8Bytes("lskf");
+    private static final byte[] TEST_KEY_CLAIMANT = getUtf8Bytes("0000111122223333");
+
+    @Test
+    public void size_isZeroForEmpty() {
+        assertEquals(0, new RecoverySessionStorage().size());
+    }
+
+    @Test
+    public void size_incrementsAfterAdd() {
+        RecoverySessionStorage storage = new RecoverySessionStorage();
+        storage.add(TEST_USER_ID, new RecoverySessionStorage.Entry(
+                TEST_SESSION_ID, lskfHashFixture(), keyClaimantFixture()));
+
+        assertEquals(1, storage.size());
+    }
+
+    @Test
+    public void size_decrementsAfterRemove() {
+        RecoverySessionStorage storage = new RecoverySessionStorage();
+        storage.add(TEST_USER_ID, new RecoverySessionStorage.Entry(
+                TEST_SESSION_ID, lskfHashFixture(), keyClaimantFixture()));
+        storage.remove(TEST_USER_ID);
+
+        assertEquals(0, storage.size());
+    }
+
+    @Test
+    public void remove_overwritesLskfHashMemory() {
+        RecoverySessionStorage storage = new RecoverySessionStorage();
+        RecoverySessionStorage.Entry entry = new RecoverySessionStorage.Entry(
+                TEST_SESSION_ID, lskfHashFixture(), keyClaimantFixture());
+        storage.add(TEST_USER_ID, entry);
+
+        storage.remove(TEST_USER_ID);
+
+        assertZeroedOut(entry.getLskfHash());
+    }
+
+    @Test
+    public void remove_overwritesKeyClaimantMemory() {
+        RecoverySessionStorage storage = new RecoverySessionStorage();
+        RecoverySessionStorage.Entry entry = new RecoverySessionStorage.Entry(
+                TEST_SESSION_ID, lskfHashFixture(), keyClaimantFixture());
+        storage.add(TEST_USER_ID, entry);
+
+        storage.remove(TEST_USER_ID);
+
+        assertZeroedOut(entry.getKeyClaimant());
+    }
+
+    @Test
+    public void destroy_overwritesLskfHashMemory() {
+        RecoverySessionStorage storage = new RecoverySessionStorage();
+        RecoverySessionStorage.Entry entry = new RecoverySessionStorage.Entry(
+                TEST_SESSION_ID, lskfHashFixture(), keyClaimantFixture());
+        storage.add(TEST_USER_ID, entry);
+
+        storage.destroy();
+
+        assertZeroedOut(entry.getLskfHash());
+    }
+
+    @Test
+    public void destroy_overwritesKeyClaimantMemory() {
+        RecoverySessionStorage storage = new RecoverySessionStorage();
+        RecoverySessionStorage.Entry entry = new RecoverySessionStorage.Entry(
+                TEST_SESSION_ID, lskfHashFixture(), keyClaimantFixture());
+        storage.add(TEST_USER_ID, entry);
+
+        storage.destroy();
+
+        assertZeroedOut(entry.getKeyClaimant());
+    }
+
+    private static void assertZeroedOut(byte[] bytes) {
+        for (byte b : bytes) {
+            if (b != (byte) 0) {
+                fail("Bytes were not all zeroed out.");
+            }
+        }
+    }
+
+    private static byte[] lskfHashFixture() {
+        return Arrays.copyOf(TEST_LSKF_HASH, TEST_LSKF_HASH.length);
+    }
+
+    private static byte[] keyClaimantFixture() {
+        return Arrays.copyOf(TEST_KEY_CLAIMANT, TEST_KEY_CLAIMANT.length);
+    }
+
+    private static byte[] getUtf8Bytes(String s) {
+        return s.getBytes(StandardCharsets.UTF_8);
+    }
+}