Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 1 | /* |
| 2 | * Copyright (C) 2017 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.server.locksettings.recoverablekeystore; |
| 18 | |
| 19 | import static android.security.recoverablekeystore.KeyStoreRecoveryMetadata.TYPE_LOCKSCREEN; |
| 20 | import static android.security.recoverablekeystore.KeyStoreRecoveryMetadata.TYPE_PASSWORD; |
| 21 | |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 22 | import static com.google.common.truth.Truth.assertThat; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 23 | import static org.junit.Assert.assertArrayEquals; |
| 24 | import static org.junit.Assert.assertEquals; |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 25 | import static org.junit.Assert.fail; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 26 | import static org.mockito.ArgumentMatchers.any; |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 27 | import static org.mockito.ArgumentMatchers.anyInt; |
| 28 | import static org.mockito.ArgumentMatchers.anyString; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 29 | import static org.mockito.ArgumentMatchers.eq; |
| 30 | import static org.mockito.Mockito.times; |
| 31 | import static org.mockito.Mockito.verify; |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 32 | import static org.mockito.Mockito.when; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 33 | |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 34 | import android.app.KeyguardManager; |
Dmitry Dementyev | 3b17c63 | 2017-12-21 17:30:48 -0800 | [diff] [blame] | 35 | import android.app.PendingIntent; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 36 | import android.content.Context; |
Dmitry Dementyev | 3b17c63 | 2017-12-21 17:30:48 -0800 | [diff] [blame] | 37 | import android.content.Intent; |
| 38 | import android.os.Binder; |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 39 | import android.os.ServiceSpecificException; |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 40 | import android.os.UserHandle; |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 41 | import android.security.keystore.AndroidKeyStoreSecretKey; |
| 42 | import android.security.keystore.KeyGenParameterSpec; |
| 43 | import android.security.keystore.KeyProperties; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 44 | import android.security.recoverablekeystore.KeyDerivationParameters; |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 45 | import android.security.recoverablekeystore.KeyEntryRecoveryData; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 46 | import android.security.recoverablekeystore.KeyStoreRecoveryMetadata; |
| 47 | import android.security.recoverablekeystore.RecoverableKeyStoreLoader; |
| 48 | import android.support.test.InstrumentationRegistry; |
| 49 | import android.support.test.filters.SmallTest; |
| 50 | import android.support.test.runner.AndroidJUnit4; |
| 51 | |
| 52 | import com.android.server.locksettings.recoverablekeystore.storage.RecoverableKeyStoreDb; |
| 53 | import com.android.server.locksettings.recoverablekeystore.storage.RecoverySessionStorage; |
Robert Berry | bd086f1 | 2017-12-27 13:29:39 +0000 | [diff] [blame] | 54 | import com.android.server.locksettings.recoverablekeystore.storage.RecoverySnapshotStorage; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 55 | |
| 56 | import com.google.common.collect.ImmutableList; |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 57 | import com.google.common.collect.ImmutableMap; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 58 | |
| 59 | import org.junit.After; |
| 60 | import org.junit.Before; |
| 61 | import org.junit.Test; |
| 62 | import org.junit.runner.RunWith; |
| 63 | import org.mockito.Mock; |
| 64 | import org.mockito.MockitoAnnotations; |
| 65 | |
| 66 | import java.io.File; |
| 67 | import java.nio.charset.StandardCharsets; |
Robert Berry | 4a534ec | 2017-12-21 15:44:02 +0000 | [diff] [blame] | 68 | import java.util.concurrent.Executors; |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 69 | import java.util.Map; |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 70 | import java.util.Random; |
| 71 | |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 72 | import javax.crypto.KeyGenerator; |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 73 | import javax.crypto.SecretKey; |
| 74 | import javax.crypto.spec.SecretKeySpec; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 75 | |
| 76 | @SmallTest |
| 77 | @RunWith(AndroidJUnit4.class) |
| 78 | public class RecoverableKeyStoreManagerTest { |
| 79 | private static final String DATABASE_FILE_NAME = "recoverablekeystore.db"; |
| 80 | |
| 81 | private static final String TEST_SESSION_ID = "karlin"; |
| 82 | private static final byte[] TEST_PUBLIC_KEY = new byte[] { |
| 83 | (byte) 0x30, (byte) 0x59, (byte) 0x30, (byte) 0x13, (byte) 0x06, (byte) 0x07, (byte) 0x2a, |
| 84 | (byte) 0x86, (byte) 0x48, (byte) 0xce, (byte) 0x3d, (byte) 0x02, (byte) 0x01, (byte) 0x06, |
| 85 | (byte) 0x08, (byte) 0x2a, (byte) 0x86, (byte) 0x48, (byte) 0xce, (byte) 0x3d, (byte) 0x03, |
| 86 | (byte) 0x01, (byte) 0x07, (byte) 0x03, (byte) 0x42, (byte) 0x00, (byte) 0x04, (byte) 0xb8, |
| 87 | (byte) 0x00, (byte) 0x11, (byte) 0x18, (byte) 0x98, (byte) 0x1d, (byte) 0xf0, (byte) 0x6e, |
| 88 | (byte) 0xb4, (byte) 0x94, (byte) 0xfe, (byte) 0x86, (byte) 0xda, (byte) 0x1c, (byte) 0x07, |
| 89 | (byte) 0x8d, (byte) 0x01, (byte) 0xb4, (byte) 0x3a, (byte) 0xf6, (byte) 0x8d, (byte) 0xdc, |
| 90 | (byte) 0x61, (byte) 0xd0, (byte) 0x46, (byte) 0x49, (byte) 0x95, (byte) 0x0f, (byte) 0x10, |
| 91 | (byte) 0x86, (byte) 0x93, (byte) 0x24, (byte) 0x66, (byte) 0xe0, (byte) 0x3f, (byte) 0xd2, |
| 92 | (byte) 0xdf, (byte) 0xf3, (byte) 0x79, (byte) 0x20, (byte) 0x1d, (byte) 0x91, (byte) 0x55, |
| 93 | (byte) 0xb0, (byte) 0xe5, (byte) 0xbd, (byte) 0x7a, (byte) 0x8b, (byte) 0x32, (byte) 0x7d, |
| 94 | (byte) 0x25, (byte) 0x53, (byte) 0xa2, (byte) 0xfc, (byte) 0xa5, (byte) 0x65, (byte) 0xe1, |
| 95 | (byte) 0xbd, (byte) 0x21, (byte) 0x44, (byte) 0x7e, (byte) 0x78, (byte) 0x52, (byte) 0xfa}; |
| 96 | private static final byte[] TEST_SALT = getUtf8Bytes("salt"); |
| 97 | private static final byte[] TEST_SECRET = getUtf8Bytes("password1234"); |
| 98 | private static final byte[] TEST_VAULT_CHALLENGE = getUtf8Bytes("vault_challenge"); |
Bo Zhu | def7ffd | 2018-01-05 14:50:52 -0800 | [diff] [blame] | 99 | private static final byte[] TEST_VAULT_PARAMS = new byte[] { |
| 100 | // backend_key |
| 101 | (byte) 0x04, (byte) 0xb8, (byte) 0x00, (byte) 0x11, (byte) 0x18, (byte) 0x98, (byte) 0x1d, |
| 102 | (byte) 0xf0, (byte) 0x6e, (byte) 0xb4, (byte) 0x94, (byte) 0xfe, (byte) 0x86, (byte) 0xda, |
| 103 | (byte) 0x1c, (byte) 0x07, (byte) 0x8d, (byte) 0x01, (byte) 0xb4, (byte) 0x3a, (byte) 0xf6, |
| 104 | (byte) 0x8d, (byte) 0xdc, (byte) 0x61, (byte) 0xd0, (byte) 0x46, (byte) 0x49, (byte) 0x95, |
| 105 | (byte) 0x0f, (byte) 0x10, (byte) 0x86, (byte) 0x93, (byte) 0x24, (byte) 0x66, (byte) 0xe0, |
| 106 | (byte) 0x3f, (byte) 0xd2, (byte) 0xdf, (byte) 0xf3, (byte) 0x79, (byte) 0x20, (byte) 0x1d, |
| 107 | (byte) 0x91, (byte) 0x55, (byte) 0xb0, (byte) 0xe5, (byte) 0xbd, (byte) 0x7a, (byte) 0x8b, |
| 108 | (byte) 0x32, (byte) 0x7d, (byte) 0x25, (byte) 0x53, (byte) 0xa2, (byte) 0xfc, (byte) 0xa5, |
| 109 | (byte) 0x65, (byte) 0xe1, (byte) 0xbd, (byte) 0x21, (byte) 0x44, (byte) 0x7e, (byte) 0x78, |
| 110 | (byte) 0x52, (byte) 0xfa, |
| 111 | // counter_id |
| 112 | (byte) 0x31, (byte) 0x32, (byte) 0x33, (byte) 0x34, (byte) 0x00, (byte) 0x00, (byte) 0x00, |
| 113 | (byte) 0x00, |
| 114 | // device_parameter |
| 115 | (byte) 0x78, (byte) 0x56, (byte) 0x34, (byte) 0x12, (byte) 0x00, (byte) 0x00, (byte) 0x00, |
| 116 | (byte) 0x0, |
| 117 | // max_attempts |
| 118 | (byte) 0x0a, (byte) 0x00, (byte) 0x00, (byte) 0x00}; |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 119 | private static final int TEST_GENERATION_ID = 2; |
| 120 | private static final int TEST_USER_ID = 10009; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 121 | private static final int KEY_CLAIMANT_LENGTH_BYTES = 16; |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 122 | private static final byte[] RECOVERY_RESPONSE_HEADER = |
| 123 | "V1 reencrypted_recovery_key".getBytes(StandardCharsets.UTF_8); |
| 124 | private static final String TEST_ALIAS = "nick"; |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 125 | private static final int RECOVERABLE_KEY_SIZE_BYTES = 32; |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 126 | private static final int GENERATION_ID = 1; |
| 127 | private static final byte[] NONCE = getUtf8Bytes("nonce"); |
| 128 | private static final byte[] KEY_MATERIAL = getUtf8Bytes("keymaterial"); |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 129 | private static final String KEY_ALGORITHM = "AES"; |
| 130 | private static final String ANDROID_KEY_STORE_PROVIDER = "AndroidKeyStore"; |
| 131 | private static final String WRAPPING_KEY_ALIAS = "RecoverableKeyStoreManagerTest/WrappingKey"; |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 132 | |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 133 | @Mock private Context mMockContext; |
Robert Berry | 9104404 | 2017-12-27 12:05:58 +0000 | [diff] [blame] | 134 | @Mock private RecoverySnapshotListenersStorage mMockListenersStorage; |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 135 | @Mock private KeyguardManager mKeyguardManager; |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 136 | @Mock private PlatformKeyManager mPlatformKeyManager; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 137 | |
| 138 | private RecoverableKeyStoreDb mRecoverableKeyStoreDb; |
| 139 | private File mDatabaseFile; |
| 140 | private RecoverableKeyStoreManager mRecoverableKeyStoreManager; |
| 141 | private RecoverySessionStorage mRecoverySessionStorage; |
Robert Berry | bd086f1 | 2017-12-27 13:29:39 +0000 | [diff] [blame] | 142 | private RecoverySnapshotStorage mRecoverySnapshotStorage; |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 143 | private PlatformEncryptionKey mPlatformEncryptionKey; |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 144 | |
| 145 | @Before |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 146 | public void setUp() throws Exception { |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 147 | MockitoAnnotations.initMocks(this); |
| 148 | |
| 149 | Context context = InstrumentationRegistry.getTargetContext(); |
| 150 | mDatabaseFile = context.getDatabasePath(DATABASE_FILE_NAME); |
| 151 | mRecoverableKeyStoreDb = RecoverableKeyStoreDb.newInstance(context); |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 152 | |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 153 | mRecoverySessionStorage = new RecoverySessionStorage(); |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 154 | |
| 155 | when(mMockContext.getSystemService(anyString())).thenReturn(mKeyguardManager); |
| 156 | when(mMockContext.getSystemServiceName(any())).thenReturn("test"); |
Robert Berry | 3ae5bea | 2017-12-27 10:58:03 +0000 | [diff] [blame] | 157 | when(mMockContext.getApplicationContext()).thenReturn(mMockContext); |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 158 | when(mKeyguardManager.isDeviceSecure(TEST_USER_ID)).thenReturn(true); |
| 159 | |
| 160 | mPlatformEncryptionKey = |
| 161 | new PlatformEncryptionKey(TEST_GENERATION_ID, generateAndroidKeyStoreKey()); |
| 162 | when(mPlatformKeyManager.getEncryptKey(anyInt())).thenReturn(mPlatformEncryptionKey); |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 163 | |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 164 | mRecoverableKeyStoreManager = new RecoverableKeyStoreManager( |
| 165 | mMockContext, |
| 166 | mRecoverableKeyStoreDb, |
Robert Berry | 4a534ec | 2017-12-21 15:44:02 +0000 | [diff] [blame] | 167 | mRecoverySessionStorage, |
Dmitry Dementyev | 3b17c63 | 2017-12-21 17:30:48 -0800 | [diff] [blame] | 168 | Executors.newSingleThreadExecutor(), |
Robert Berry | 9104404 | 2017-12-27 12:05:58 +0000 | [diff] [blame] | 169 | mRecoverySnapshotStorage, |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 170 | mMockListenersStorage, |
| 171 | mPlatformKeyManager); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 172 | } |
| 173 | |
| 174 | @After |
| 175 | public void tearDown() { |
| 176 | mRecoverableKeyStoreDb.close(); |
| 177 | mDatabaseFile.delete(); |
| 178 | } |
| 179 | |
| 180 | @Test |
Robert Berry | cfc990a | 2017-12-22 15:54:30 +0000 | [diff] [blame] | 181 | public void generateAndStoreKey_storesTheKey() throws Exception { |
| 182 | int uid = Binder.getCallingUid(); |
| 183 | |
| 184 | mRecoverableKeyStoreManager.generateAndStoreKey(TEST_ALIAS); |
| 185 | |
| 186 | assertThat(mRecoverableKeyStoreDb.getKey(uid, TEST_ALIAS)).isNotNull(); |
| 187 | } |
| 188 | |
| 189 | @Test |
| 190 | public void generateAndStoreKey_returnsAKeyOfAppropriateSize() throws Exception { |
| 191 | assertThat(mRecoverableKeyStoreManager.generateAndStoreKey(TEST_ALIAS)) |
| 192 | .hasLength(RECOVERABLE_KEY_SIZE_BYTES); |
| 193 | } |
| 194 | |
| 195 | @Test |
Robert Berry | 5daccec | 2018-01-06 19:16:25 +0000 | [diff] [blame] | 196 | public void removeKey_removesAKey() throws Exception { |
| 197 | int uid = Binder.getCallingUid(); |
| 198 | mRecoverableKeyStoreManager.generateAndStoreKey(TEST_ALIAS); |
| 199 | |
| 200 | mRecoverableKeyStoreManager.removeKey(TEST_ALIAS); |
| 201 | |
| 202 | assertThat(mRecoverableKeyStoreDb.getKey(uid, TEST_ALIAS)).isNull(); |
| 203 | } |
| 204 | |
| 205 | @Test |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 206 | public void startRecoverySession_checksPermissionFirst() throws Exception { |
| 207 | mRecoverableKeyStoreManager.startRecoverySession( |
| 208 | TEST_SESSION_ID, |
| 209 | TEST_PUBLIC_KEY, |
| 210 | TEST_VAULT_PARAMS, |
| 211 | TEST_VAULT_CHALLENGE, |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 212 | ImmutableList.of( |
| 213 | new KeyStoreRecoveryMetadata( |
| 214 | TYPE_LOCKSCREEN, |
| 215 | TYPE_PASSWORD, |
| 216 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 217 | TEST_SECRET))); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 218 | |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 219 | verify(mMockContext, times(1)) |
| 220 | .enforceCallingOrSelfPermission( |
| 221 | eq(RecoverableKeyStoreLoader.PERMISSION_RECOVER_KEYSTORE), any()); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 222 | } |
| 223 | |
| 224 | @Test |
| 225 | public void startRecoverySession_storesTheSessionInfo() throws Exception { |
| 226 | mRecoverableKeyStoreManager.startRecoverySession( |
| 227 | TEST_SESSION_ID, |
| 228 | TEST_PUBLIC_KEY, |
| 229 | TEST_VAULT_PARAMS, |
| 230 | TEST_VAULT_CHALLENGE, |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 231 | ImmutableList.of( |
| 232 | new KeyStoreRecoveryMetadata( |
| 233 | TYPE_LOCKSCREEN, |
| 234 | TYPE_PASSWORD, |
| 235 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 236 | TEST_SECRET))); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 237 | |
| 238 | assertEquals(1, mRecoverySessionStorage.size()); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 239 | RecoverySessionStorage.Entry entry = |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 240 | mRecoverySessionStorage.get(Binder.getCallingUid(), TEST_SESSION_ID); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 241 | assertArrayEquals(TEST_SECRET, entry.getLskfHash()); |
| 242 | assertEquals(KEY_CLAIMANT_LENGTH_BYTES, entry.getKeyClaimant().length); |
| 243 | } |
| 244 | |
| 245 | @Test |
| 246 | public void startRecoverySession_throwsIfBadNumberOfSecrets() throws Exception { |
| 247 | try { |
| 248 | mRecoverableKeyStoreManager.startRecoverySession( |
| 249 | TEST_SESSION_ID, |
| 250 | TEST_PUBLIC_KEY, |
| 251 | TEST_VAULT_PARAMS, |
| 252 | TEST_VAULT_CHALLENGE, |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 253 | ImmutableList.of()); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 254 | fail("should have thrown"); |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 255 | } catch (ServiceSpecificException e) { |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 256 | assertEquals("Only a single KeyStoreRecoveryMetadata is supported", e.getMessage()); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 257 | } |
| 258 | } |
| 259 | |
| 260 | @Test |
| 261 | public void startRecoverySession_throwsIfBadKey() throws Exception { |
| 262 | try { |
| 263 | mRecoverableKeyStoreManager.startRecoverySession( |
| 264 | TEST_SESSION_ID, |
| 265 | getUtf8Bytes("0"), |
| 266 | TEST_VAULT_PARAMS, |
| 267 | TEST_VAULT_CHALLENGE, |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 268 | ImmutableList.of( |
| 269 | new KeyStoreRecoveryMetadata( |
| 270 | TYPE_LOCKSCREEN, |
| 271 | TYPE_PASSWORD, |
| 272 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 273 | TEST_SECRET))); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 274 | fail("should have thrown"); |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 275 | } catch (ServiceSpecificException e) { |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 276 | assertEquals("Not a valid X509 key", e.getMessage()); |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 277 | } |
| 278 | } |
| 279 | |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 280 | @Test |
Bo Zhu | def7ffd | 2018-01-05 14:50:52 -0800 | [diff] [blame] | 281 | public void startRecoverySession_throwsIfPublicKeysMismatch() throws Exception { |
| 282 | byte[] vaultParams = TEST_VAULT_PARAMS.clone(); |
| 283 | vaultParams[1] ^= (byte) 1; // Flip 1 bit |
| 284 | try { |
| 285 | mRecoverableKeyStoreManager.startRecoverySession( |
| 286 | TEST_SESSION_ID, |
| 287 | TEST_PUBLIC_KEY, |
| 288 | vaultParams, |
| 289 | TEST_VAULT_CHALLENGE, |
| 290 | ImmutableList.of( |
| 291 | new KeyStoreRecoveryMetadata( |
| 292 | TYPE_LOCKSCREEN, |
| 293 | TYPE_PASSWORD, |
| 294 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
| 295 | TEST_SECRET))); |
| 296 | fail("should have thrown"); |
| 297 | } catch (ServiceSpecificException e) { |
| 298 | assertThat(e.getMessage()).contains("do not match"); |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | @Test |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 303 | public void recoverKeys_throwsIfNoSessionIsPresent() throws Exception { |
| 304 | try { |
| 305 | mRecoverableKeyStoreManager.recoverKeys( |
| 306 | TEST_SESSION_ID, |
| 307 | /*recoveryKeyBlob=*/ randomBytes(32), |
| 308 | /*applicationKeys=*/ ImmutableList.of( |
Dmitry Dementyev | 07c76555 | 2018-01-08 17:31:59 -0800 | [diff] [blame] | 309 | new KeyEntryRecoveryData("alias", randomBytes(32)) |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 310 | )); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 311 | fail("should have thrown"); |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 312 | } catch (ServiceSpecificException e) { |
| 313 | // expected |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 314 | } |
| 315 | } |
| 316 | |
| 317 | @Test |
| 318 | public void recoverKeys_throwsIfRecoveryClaimCannotBeDecrypted() throws Exception { |
| 319 | mRecoverableKeyStoreManager.startRecoverySession( |
| 320 | TEST_SESSION_ID, |
| 321 | TEST_PUBLIC_KEY, |
| 322 | TEST_VAULT_PARAMS, |
| 323 | TEST_VAULT_CHALLENGE, |
| 324 | ImmutableList.of(new KeyStoreRecoveryMetadata( |
| 325 | TYPE_LOCKSCREEN, |
| 326 | TYPE_PASSWORD, |
| 327 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 328 | TEST_SECRET))); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 329 | |
| 330 | try { |
| 331 | mRecoverableKeyStoreManager.recoverKeys( |
| 332 | TEST_SESSION_ID, |
| 333 | /*encryptedRecoveryKey=*/ randomBytes(60), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 334 | /*applicationKeys=*/ ImmutableList.of()); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 335 | fail("should have thrown"); |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 336 | } catch (ServiceSpecificException e) { |
| 337 | assertThat(e.getMessage()).startsWith("Failed to decrypt recovery key"); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 338 | } |
| 339 | } |
| 340 | |
| 341 | @Test |
| 342 | public void recoverKeys_throwsIfFailedToDecryptAnApplicationKey() throws Exception { |
| 343 | mRecoverableKeyStoreManager.startRecoverySession( |
| 344 | TEST_SESSION_ID, |
| 345 | TEST_PUBLIC_KEY, |
| 346 | TEST_VAULT_PARAMS, |
| 347 | TEST_VAULT_CHALLENGE, |
| 348 | ImmutableList.of(new KeyStoreRecoveryMetadata( |
| 349 | TYPE_LOCKSCREEN, |
| 350 | TYPE_PASSWORD, |
| 351 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 352 | TEST_SECRET))); |
| 353 | byte[] keyClaimant = mRecoverySessionStorage.get(Binder.getCallingUid(), TEST_SESSION_ID) |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 354 | .getKeyClaimant(); |
| 355 | SecretKey recoveryKey = randomRecoveryKey(); |
| 356 | byte[] encryptedClaimResponse = encryptClaimResponse( |
| 357 | keyClaimant, TEST_SECRET, TEST_VAULT_PARAMS, recoveryKey); |
| 358 | KeyEntryRecoveryData badApplicationKey = new KeyEntryRecoveryData( |
Dmitry Dementyev | 07c76555 | 2018-01-08 17:31:59 -0800 | [diff] [blame] | 359 | TEST_ALIAS, |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 360 | randomBytes(32)); |
| 361 | |
| 362 | try { |
| 363 | mRecoverableKeyStoreManager.recoverKeys( |
| 364 | TEST_SESSION_ID, |
| 365 | /*encryptedRecoveryKey=*/ encryptedClaimResponse, |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 366 | /*applicationKeys=*/ ImmutableList.of(badApplicationKey)); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 367 | fail("should have thrown"); |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 368 | } catch (ServiceSpecificException e) { |
| 369 | assertThat(e.getMessage()).startsWith("Failed to recover key with alias 'nick'"); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 370 | } |
| 371 | } |
| 372 | |
| 373 | @Test |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 374 | public void recoverKeys_returnsDecryptedKeys() throws Exception { |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 375 | mRecoverableKeyStoreManager.startRecoverySession( |
| 376 | TEST_SESSION_ID, |
| 377 | TEST_PUBLIC_KEY, |
| 378 | TEST_VAULT_PARAMS, |
| 379 | TEST_VAULT_CHALLENGE, |
| 380 | ImmutableList.of(new KeyStoreRecoveryMetadata( |
| 381 | TYPE_LOCKSCREEN, |
| 382 | TYPE_PASSWORD, |
| 383 | KeyDerivationParameters.createSHA256Parameters(TEST_SALT), |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 384 | TEST_SECRET))); |
| 385 | byte[] keyClaimant = mRecoverySessionStorage.get(Binder.getCallingUid(), TEST_SESSION_ID) |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 386 | .getKeyClaimant(); |
| 387 | SecretKey recoveryKey = randomRecoveryKey(); |
| 388 | byte[] encryptedClaimResponse = encryptClaimResponse( |
| 389 | keyClaimant, TEST_SECRET, TEST_VAULT_PARAMS, recoveryKey); |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 390 | byte[] applicationKeyBytes = randomBytes(32); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 391 | KeyEntryRecoveryData applicationKey = new KeyEntryRecoveryData( |
Dmitry Dementyev | 07c76555 | 2018-01-08 17:31:59 -0800 | [diff] [blame] | 392 | TEST_ALIAS, |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 393 | encryptedApplicationKey(recoveryKey, applicationKeyBytes)); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 394 | |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 395 | Map<String, byte[]> recoveredKeys = mRecoverableKeyStoreManager.recoverKeys( |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 396 | TEST_SESSION_ID, |
| 397 | encryptedClaimResponse, |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 398 | ImmutableList.of(applicationKey)); |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 399 | |
| 400 | assertThat(recoveredKeys).hasSize(1); |
| 401 | assertThat(recoveredKeys.get(TEST_ALIAS)).isEqualTo(applicationKeyBytes); |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 402 | } |
| 403 | |
Dmitry Dementyev | 3b17c63 | 2017-12-21 17:30:48 -0800 | [diff] [blame] | 404 | @Test |
| 405 | public void setSnapshotCreatedPendingIntent() throws Exception { |
| 406 | int uid = Binder.getCallingUid(); |
| 407 | PendingIntent intent = PendingIntent.getBroadcast( |
| 408 | InstrumentationRegistry.getTargetContext(), /*requestCode=*/1, |
| 409 | new Intent(), /*flags=*/ 0); |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 410 | mRecoverableKeyStoreManager.setSnapshotCreatedPendingIntent(intent); |
Dmitry Dementyev | 3b17c63 | 2017-12-21 17:30:48 -0800 | [diff] [blame] | 411 | verify(mMockListenersStorage).setSnapshotListener(eq(uid), any(PendingIntent.class)); |
| 412 | } |
| 413 | |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 414 | @Test |
Dmitry Dementyev | bdfdf53 | 2017-12-27 11:58:45 -0800 | [diff] [blame] | 415 | public void setRecoverySecretTypes() throws Exception { |
Dmitry Dementyev | bdfdf53 | 2017-12-27 11:58:45 -0800 | [diff] [blame] | 416 | int[] types1 = new int[]{11, 2000}; |
| 417 | int[] types2 = new int[]{1, 2, 3}; |
| 418 | int[] types3 = new int[]{}; |
| 419 | |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 420 | mRecoverableKeyStoreManager.setRecoverySecretTypes(types1); |
| 421 | assertThat(mRecoverableKeyStoreManager.getRecoverySecretTypes()).isEqualTo( |
Dmitry Dementyev | bdfdf53 | 2017-12-27 11:58:45 -0800 | [diff] [blame] | 422 | types1); |
| 423 | |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 424 | mRecoverableKeyStoreManager.setRecoverySecretTypes(types2); |
| 425 | assertThat(mRecoverableKeyStoreManager.getRecoverySecretTypes()).isEqualTo( |
Dmitry Dementyev | bdfdf53 | 2017-12-27 11:58:45 -0800 | [diff] [blame] | 426 | types2); |
| 427 | |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 428 | mRecoverableKeyStoreManager.setRecoverySecretTypes(types3); |
| 429 | assertThat(mRecoverableKeyStoreManager.getRecoverySecretTypes()).isEqualTo( |
Dmitry Dementyev | bdfdf53 | 2017-12-27 11:58:45 -0800 | [diff] [blame] | 430 | types3); |
| 431 | } |
| 432 | |
| 433 | @Test |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 434 | public void setRecoveryStatus_forOneAlias() throws Exception { |
| 435 | int userId = UserHandle.getCallingUserId(); |
| 436 | int uid = Binder.getCallingUid(); |
| 437 | int status = 100; |
| 438 | int status2 = 200; |
| 439 | String alias = "key1"; |
| 440 | WrappedKey wrappedKey = new WrappedKey(NONCE, KEY_MATERIAL, GENERATION_ID, status); |
| 441 | mRecoverableKeyStoreDb.insertKey(userId, uid, alias, wrappedKey); |
| 442 | Map<String, Integer> statuses = |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 443 | mRecoverableKeyStoreManager.getRecoveryStatus(/*packageName=*/ null); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 444 | assertThat(statuses).hasSize(1); |
| 445 | assertThat(statuses).containsEntry(alias, status); |
| 446 | |
| 447 | mRecoverableKeyStoreManager.setRecoveryStatus( |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 448 | /*packageName=*/ null, new String[] {alias}, status2); |
| 449 | statuses = mRecoverableKeyStoreManager.getRecoveryStatus(/*packageName=*/ null); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 450 | assertThat(statuses).hasSize(1); |
| 451 | assertThat(statuses).containsEntry(alias, status2); // updated |
| 452 | } |
| 453 | |
| 454 | @Test |
| 455 | public void setRecoveryStatus_for2Aliases() throws Exception { |
| 456 | int userId = UserHandle.getCallingUserId(); |
| 457 | int uid = Binder.getCallingUid(); |
| 458 | int status = 100; |
| 459 | int status2 = 200; |
| 460 | int status3 = 300; |
| 461 | String alias = "key1"; |
| 462 | String alias2 = "key2"; |
| 463 | WrappedKey wrappedKey = new WrappedKey(NONCE, KEY_MATERIAL, GENERATION_ID, status); |
| 464 | mRecoverableKeyStoreDb.insertKey(userId, uid, alias, wrappedKey); |
| 465 | mRecoverableKeyStoreDb.insertKey(userId, uid, alias2, wrappedKey); |
| 466 | Map<String, Integer> statuses = |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 467 | mRecoverableKeyStoreManager.getRecoveryStatus(/*packageName=*/ null); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 468 | assertThat(statuses).hasSize(2); |
| 469 | assertThat(statuses).containsEntry(alias, status); |
| 470 | assertThat(statuses).containsEntry(alias2, status); |
| 471 | |
| 472 | mRecoverableKeyStoreManager.setRecoveryStatus( |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 473 | /*packageName=*/ null, /*aliases=*/ null, status2); |
| 474 | statuses = mRecoverableKeyStoreManager.getRecoveryStatus(/*packageName=*/ null); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 475 | assertThat(statuses).hasSize(2); |
| 476 | assertThat(statuses).containsEntry(alias, status2); // updated |
| 477 | assertThat(statuses).containsEntry(alias2, status2); // updated |
| 478 | |
| 479 | mRecoverableKeyStoreManager.setRecoveryStatus( |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 480 | /*packageName=*/ null, new String[] {alias2}, status3); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 481 | |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 482 | statuses = mRecoverableKeyStoreManager.getRecoveryStatus(/*packageName=*/ null); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 483 | assertThat(statuses).hasSize(2); |
| 484 | assertThat(statuses).containsEntry(alias, status2); |
| 485 | assertThat(statuses).containsEntry(alias2, status3); // updated |
| 486 | |
| 487 | mRecoverableKeyStoreManager.setRecoveryStatus( |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 488 | /*packageName=*/ null, new String[] {alias, alias2}, status); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 489 | |
Dmitry Dementyev | 1429831 | 2018-01-04 15:19:19 -0800 | [diff] [blame] | 490 | statuses = mRecoverableKeyStoreManager.getRecoveryStatus(/*packageName=*/ null); |
Dmitry Dementyev | ad88471 | 2017-12-20 12:38:36 -0800 | [diff] [blame] | 491 | assertThat(statuses).hasSize(2); |
| 492 | assertThat(statuses).containsEntry(alias, status); // updated |
| 493 | assertThat(statuses).containsEntry(alias2, status); // updated |
| 494 | } |
| 495 | |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 496 | private static byte[] encryptedApplicationKey( |
| 497 | SecretKey recoveryKey, byte[] applicationKey) throws Exception { |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 498 | return KeySyncUtils.encryptKeysWithRecoveryKey(recoveryKey, ImmutableMap.of( |
Robert Berry | bd4c43c | 2017-12-22 11:35:14 +0000 | [diff] [blame] | 499 | "alias", new SecretKeySpec(applicationKey, "AES") |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 500 | )).get("alias"); |
| 501 | } |
| 502 | |
| 503 | private static byte[] encryptClaimResponse( |
| 504 | byte[] keyClaimant, |
| 505 | byte[] lskfHash, |
| 506 | byte[] vaultParams, |
| 507 | SecretKey recoveryKey) throws Exception { |
| 508 | byte[] locallyEncryptedRecoveryKey = KeySyncUtils.locallyEncryptRecoveryKey( |
| 509 | lskfHash, recoveryKey); |
| 510 | return SecureBox.encrypt( |
| 511 | /*theirPublicKey=*/ null, |
| 512 | /*sharedSecret=*/ keyClaimant, |
| 513 | /*header=*/ KeySyncUtils.concat(RECOVERY_RESPONSE_HEADER, vaultParams), |
| 514 | /*payload=*/ locallyEncryptedRecoveryKey); |
| 515 | } |
| 516 | |
| 517 | private static SecretKey randomRecoveryKey() { |
| 518 | return new SecretKeySpec(randomBytes(32), "AES"); |
| 519 | } |
| 520 | |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 521 | private static byte[] getUtf8Bytes(String s) { |
| 522 | return s.getBytes(StandardCharsets.UTF_8); |
| 523 | } |
Robert Berry | b9a220b | 2017-12-21 12:41:01 +0000 | [diff] [blame] | 524 | |
| 525 | private static byte[] randomBytes(int n) { |
| 526 | byte[] bytes = new byte[n]; |
| 527 | new Random().nextBytes(bytes); |
| 528 | return bytes; |
| 529 | } |
Bo Zhu | 3462c83 | 2018-01-04 22:42:36 -0800 | [diff] [blame] | 530 | |
| 531 | private AndroidKeyStoreSecretKey generateAndroidKeyStoreKey() throws Exception { |
| 532 | KeyGenerator keyGenerator = KeyGenerator.getInstance( |
| 533 | KEY_ALGORITHM, |
| 534 | ANDROID_KEY_STORE_PROVIDER); |
| 535 | keyGenerator.init(new KeyGenParameterSpec.Builder( |
| 536 | WRAPPING_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) |
| 537 | .setBlockModes(KeyProperties.BLOCK_MODE_GCM) |
| 538 | .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) |
| 539 | .build()); |
| 540 | return (AndroidKeyStoreSecretKey) keyGenerator.generateKey(); |
| 541 | } |
Robert Berry | e16fa98 | 2017-12-20 15:59:37 +0000 | [diff] [blame] | 542 | } |