Merge pull request #209 from grote/main-key
Store main key for key derivations from 512-bit BIP39 recovery code
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index 295f906..a99e08a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -34,7 +34,7 @@
* @author Steve Soltys
* @author Torsten Grote
*/
-class App : Application() {
+open class App : Application() {
private val appModule = module {
single { SettingsManager(this@App) }
@@ -52,22 +52,7 @@
override fun onCreate() {
super.onCreate()
- startKoin {
- androidLogger()
- androidContext(this@App)
- modules(
- listOf(
- cryptoModule,
- headerModule,
- metadataModule,
- documentsProviderModule, // storage plugin
- backupModule,
- restoreModule,
- installModule,
- appModule
- )
- )
- }
+ startKoin()
if (isDebugBuild()) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
@@ -88,6 +73,23 @@
}
}
+ protected open fun startKoin() = startKoin {
+ androidLogger()
+ androidContext(this@App)
+ modules(
+ listOf(
+ cryptoModule,
+ headerModule,
+ metadataModule,
+ documentsProviderModule, // storage plugin
+ backupModule,
+ restoreModule,
+ installModule,
+ appModule
+ )
+ )
+ }
+
private val settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt
index 7beb019..d15c960 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/CryptoModule.kt
@@ -1,9 +1,19 @@
package com.stevesoltys.seedvault.crypto
import org.koin.dsl.module
+import java.security.KeyStore
+
+private const val ANDROID_KEY_STORE = "AndroidKeyStore"
val cryptoModule = module {
factory<CipherFactory> { CipherFactoryImpl(get()) }
- single<KeyManager> { KeyManagerImpl() }
+ single<KeyManager> {
+ val keyStore by lazy {
+ KeyStore.getInstance(ANDROID_KEY_STORE).apply {
+ load(null)
+ }
+ }
+ KeyManagerImpl(keyStore)
+ }
single<Crypto> { CryptoImpl(get(), get(), get()) }
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt
index eedbdee..35ffaad 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/KeyManager.kt
@@ -4,6 +4,8 @@
import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
+import android.security.keystore.KeyProperties.PURPOSE_SIGN
+import android.security.keystore.KeyProperties.PURPOSE_VERIFY
import android.security.keystore.KeyProtection
import java.security.KeyStore
import java.security.KeyStore.SecretKeyEntry
@@ -12,8 +14,10 @@
internal const val KEY_SIZE = 256
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
-private const val KEY_ALIAS = "com.stevesoltys.seedvault"
-private const val ANDROID_KEY_STORE = "AndroidKeyStore"
+private const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
+private const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
+private const val KEY_ALGORITHM_BACKUP = "AES"
+private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
interface KeyManager {
/**
@@ -24,43 +28,73 @@
fun storeBackupKey(seed: ByteArray)
/**
+ * Store a new main key derived from the given [seed].
+ *
+ * The seed needs to be larger or equal to two times [KEY_SIZE_BYTES]
+ * and is usually the same as for [storeBackupKey].
+ */
+ fun storeMainKey(seed: ByteArray)
+
+ /**
* @return true if a backup key already exists in the [KeyStore].
*/
fun hasBackupKey(): Boolean
/**
+ * @return true if a main key already exists in the [KeyStore].
+ */
+ fun hasMainKey(): Boolean
+
+ /**
* Returns the backup key, so it can be used for encryption or decryption.
*
* Note that any attempt to export the key will return null or an empty [ByteArray],
* because the key can not leave the [KeyStore]'s hardware security module.
*/
fun getBackupKey(): SecretKey
+
+ /**
+ * Returns the main key, so it can be used for deriving sub-keys.
+ *
+ * Note that any attempt to export the key will return null or an empty [ByteArray],
+ * because the key can not leave the [KeyStore]'s hardware security module.
+ */
+ fun getMainKey(): SecretKey
}
-internal class KeyManagerImpl : KeyManager {
-
- private val keyStore by lazy {
- KeyStore.getInstance(ANDROID_KEY_STORE).apply {
- load(null)
- }
- }
+internal class KeyManagerImpl(
+ private val keyStore: KeyStore
+) : KeyManager {
override fun storeBackupKey(seed: ByteArray) {
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
- val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
- val ksEntry = SecretKeyEntry(secretKeySpec)
- keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
+ val backupKeyEntry =
+ SecretKeyEntry(SecretKeySpec(seed, 0, KEY_SIZE_BYTES, KEY_ALGORITHM_BACKUP))
+ keyStore.setEntry(KEY_ALIAS_BACKUP, backupKeyEntry, getBackupKeyProtection())
}
- override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
- keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java)
+ override fun storeMainKey(seed: ByteArray) {
+ if (seed.size < KEY_SIZE_BYTES * 2) throw IllegalArgumentException()
+ val mainKeyEntry =
+ SecretKeyEntry(SecretKeySpec(seed, KEY_SIZE_BYTES, KEY_SIZE_BYTES, KEY_ALGORITHM_MAIN))
+ keyStore.setEntry(KEY_ALIAS_MAIN, mainKeyEntry, getMainKeyProtection())
+ }
+
+ override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS_BACKUP)
+
+ override fun hasMainKey() = keyStore.containsAlias(KEY_ALIAS_MAIN)
override fun getBackupKey(): SecretKey {
- val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
+ val ksEntry = keyStore.getEntry(KEY_ALIAS_BACKUP, null) as SecretKeyEntry
return ksEntry.secretKey
}
- private fun getKeyProtection(): KeyProtection {
+ override fun getMainKey(): SecretKey {
+ val ksEntry = keyStore.getEntry(KEY_ALIAS_MAIN, null) as SecretKeyEntry
+ return ksEntry.secretKey
+ }
+
+ private fun getBackupKeyProtection(): KeyProtection {
val builder = KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
@@ -70,4 +104,14 @@
return builder.build()
}
+ private fun getMainKeyProtection(): KeyProtection {
+ // let's not lock down the main key too much, because we have no second chance
+ // and don't want to repeat the issue with the locked down backup key
+ val builder = KeyProtection.Builder(
+ PURPOSE_ENCRYPT or PURPOSE_DECRYPT or
+ PURPOSE_SIGN or PURPOSE_VERIFY
+ )
+ return builder.build()
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt
index 74f9d75..c306947 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt
@@ -70,9 +70,12 @@
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
if (forVerifyingNewCode) {
keyManager.storeBackupKey(seed)
+ keyManager.storeMainKey(seed)
mRecoveryCodeSaved.setEvent(true)
} else {
- mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed))
+ val verified = crypto.verifyBackupKey(seed)
+ if (verified && !keyManager.hasMainKey()) keyManager.storeMainKey(seed)
+ mExistingCodeChecked.setEvent(verified)
}
}
diff --git a/app/src/sharedTest/java/com/stevesoltys/seedvault/crypto/KeyManagerTestImpl.kt b/app/src/sharedTest/java/com/stevesoltys/seedvault/crypto/KeyManagerTestImpl.kt
index bce7f8a..372f023 100644
--- a/app/src/sharedTest/java/com/stevesoltys/seedvault/crypto/KeyManagerTestImpl.kt
+++ b/app/src/sharedTest/java/com/stevesoltys/seedvault/crypto/KeyManagerTestImpl.kt
@@ -15,12 +15,24 @@
throw NotImplementedError("not implemented")
}
+ override fun storeMainKey(seed: ByteArray) {
+ throw NotImplementedError("not implemented")
+ }
+
override fun hasBackupKey(): Boolean {
return true
}
+ override fun hasMainKey(): Boolean {
+ throw NotImplementedError("not implemented")
+ }
+
override fun getBackupKey(): SecretKey {
return key
}
+ override fun getMainKey(): SecretKey {
+ throw NotImplementedError("not implemented")
+ }
+
}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
new file mode 100644
index 0000000..b1ad45a
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
@@ -0,0 +1,45 @@
+package com.stevesoltys.seedvault
+
+import com.stevesoltys.seedvault.crypto.CipherFactory
+import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
+import com.stevesoltys.seedvault.crypto.Crypto
+import com.stevesoltys.seedvault.crypto.CryptoImpl
+import com.stevesoltys.seedvault.crypto.KeyManager
+import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
+import com.stevesoltys.seedvault.header.headerModule
+import com.stevesoltys.seedvault.metadata.metadataModule
+import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
+import com.stevesoltys.seedvault.restore.install.installModule
+import com.stevesoltys.seedvault.transport.backup.backupModule
+import com.stevesoltys.seedvault.transport.restore.restoreModule
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+import org.koin.dsl.module
+
+class TestApp : App() {
+
+ private val testCryptoModule = module {
+ factory<CipherFactory> { CipherFactoryImpl(get()) }
+ single<KeyManager> { KeyManagerTestImpl() }
+ single<Crypto> { CryptoImpl(get(), get(), get()) }
+ }
+ private val appModule = module {
+ single { Clock() }
+ }
+
+ override fun startKoin() = startKoin {
+ androidContext(this@TestApp)
+ modules(
+ listOf(
+ testCryptoModule,
+ headerModule,
+ metadataModule,
+ documentsProviderModule, // storage plugin
+ backupModule,
+ restoreModule,
+ installModule,
+ appModule
+ )
+ )
+ }
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/crypto/KeyManagerImplTest.kt b/app/src/test/java/com/stevesoltys/seedvault/crypto/KeyManagerImplTest.kt
new file mode 100644
index 0000000..7f7ee53
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/crypto/KeyManagerImplTest.kt
@@ -0,0 +1,65 @@
+package com.stevesoltys.seedvault.crypto
+
+import com.stevesoltys.seedvault.getRandomByteArray
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import junit.framework.Assert.assertTrue
+import org.junit.Assert.assertArrayEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
+import org.junit.jupiter.api.assertThrows
+import java.security.KeyStore
+
+@TestInstance(PER_METHOD)
+class KeyManagerImplTest {
+
+ private val keyStore: KeyStore = mockk()
+ private val keyManager = KeyManagerImpl(keyStore)
+
+ @Test
+ fun `31 byte seed gets rejected for backup key`() {
+ val seed = getRandomByteArray(31)
+ assertThrows<IllegalArgumentException> {
+ keyManager.storeBackupKey(seed)
+ }
+ }
+
+ @Test
+ fun `63 byte seed gets rejected for main key`() {
+ val seed = getRandomByteArray(63)
+ assertThrows<IllegalArgumentException> {
+ keyManager.storeMainKey(seed)
+ }
+ }
+
+ @Test
+ fun `32 byte seed gets accepted for backup key`() {
+ val seed = getRandomByteArray(32)
+ val keyEntry = slot<KeyStore.SecretKeyEntry>()
+
+ every { keyStore.setEntry(any(), capture(keyEntry), any()) } just Runs
+
+ keyManager.storeBackupKey(seed)
+
+ assertTrue(keyEntry.isCaptured)
+ assertArrayEquals(seed.sliceArray(0 until 32), keyEntry.captured.secretKey.encoded)
+ }
+
+ @Test
+ fun `64 byte seed gets accepted for main key`() {
+ val seed = getRandomByteArray(64)
+ val keyEntry = slot<KeyStore.SecretKeyEntry>()
+
+ every { keyStore.setEntry(any(), capture(keyEntry), any()) } just Runs
+
+ keyManager.storeMainKey(seed)
+
+ assertTrue(keyEntry.isCaptured)
+ assertArrayEquals(seed.sliceArray(32 until 64), keyEntry.captured.secretKey.encoded)
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
index 6b35601..0aae411 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
@@ -8,6 +8,7 @@
import android.content.pm.PackageInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock
+import com.stevesoltys.seedvault.TestApp
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
@@ -37,7 +38,10 @@
@Suppress("DEPRECATION")
@RunWith(AndroidJUnit4::class)
-@Config(sdk = [29]) // robolectric does not support 30, yet
+@Config(
+ sdk = [29], // robolectric does not support 30, yet
+ application = TestApp::class
+)
class MetadataManagerTest {
private val context: Context = mockk()
diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
index 83ed6e2..49a182e 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
@@ -5,6 +5,7 @@
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.stevesoltys.seedvault.TestApp
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
@@ -15,7 +16,10 @@
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
-@Config(sdk = [29]) // robolectric does not support 30, yet
+@Config(
+ sdk = [29], // robolectric does not support 30, yet
+ application = TestApp::class
+)
internal class DocumentFileTest {
private val context: Context = mockk()
diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt
index 4afe4ec..414f5c1 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt
@@ -6,6 +6,7 @@
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.R
+import com.stevesoltys.seedvault.TestApp
import com.stevesoltys.seedvault.getRandomString
import io.mockk.every
import io.mockk.mockk
@@ -20,7 +21,10 @@
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
-@Config(sdk = [29]) // robolectric does not support 30, yet
+@Config(
+ sdk = [29], // robolectric does not support 30, yet
+ application = TestApp::class
+)
internal class DeviceInfoTest {
@After