Merge pull request #209 from grote/main-key

Store main key for key derivations from 512-bit BIP39 recovery code
This commit is contained in:
Torsten Grote 2021-02-17 09:41:16 -03:00 committed by GitHub
commit ecdc0c2716
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 231 additions and 38 deletions

View file

@ -34,7 +34,7 @@ import org.koin.dsl.module
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
*/ */
class App : Application() { open class App : Application() {
private val appModule = module { private val appModule = module {
single { SettingsManager(this@App) } single { SettingsManager(this@App) }
@ -52,22 +52,7 @@ class App : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startKoin { startKoin()
androidLogger()
androidContext(this@App)
modules(
listOf(
cryptoModule,
headerModule,
metadataModule,
documentsProviderModule, // storage plugin
backupModule,
restoreModule,
installModule,
appModule
)
)
}
if (isDebugBuild()) { if (isDebugBuild()) {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()
@ -88,6 +73,23 @@ class App : Application() {
} }
} }
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 settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject() private val metadataManager: MetadataManager by inject()

View file

@ -1,9 +1,19 @@
package com.stevesoltys.seedvault.crypto package com.stevesoltys.seedvault.crypto
import org.koin.dsl.module import org.koin.dsl.module
import java.security.KeyStore
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
val cryptoModule = module { val cryptoModule = module {
factory<CipherFactory> { CipherFactoryImpl(get()) } 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()) } single<Crypto> { CryptoImpl(get(), get(), get()) }
} }

View file

@ -4,6 +4,8 @@ import android.security.keystore.KeyProperties.BLOCK_MODE_GCM
import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT 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 android.security.keystore.KeyProtection
import java.security.KeyStore import java.security.KeyStore
import java.security.KeyStore.SecretKeyEntry import java.security.KeyStore.SecretKeyEntry
@ -12,8 +14,10 @@ import javax.crypto.spec.SecretKeySpec
internal const val KEY_SIZE = 256 internal const val KEY_SIZE = 256
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8 internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
private const val KEY_ALIAS = "com.stevesoltys.seedvault" private const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
private const val ANDROID_KEY_STORE = "AndroidKeyStore" 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 { interface KeyManager {
/** /**
@ -23,11 +27,24 @@ interface KeyManager {
*/ */
fun storeBackupKey(seed: ByteArray) 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]. * @return true if a backup key already exists in the [KeyStore].
*/ */
fun hasBackupKey(): Boolean 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. * Returns the backup key, so it can be used for encryption or decryption.
* *
@ -35,32 +52,49 @@ interface KeyManager {
* because the key can not leave the [KeyStore]'s hardware security module. * because the key can not leave the [KeyStore]'s hardware security module.
*/ */
fun getBackupKey(): SecretKey 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 { internal class KeyManagerImpl(
private val keyStore: KeyStore
private val keyStore by lazy { ) : KeyManager {
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
load(null)
}
}
override fun storeBackupKey(seed: ByteArray) { override fun storeBackupKey(seed: ByteArray) {
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException() if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") val backupKeyEntry =
val ksEntry = SecretKeyEntry(secretKeySpec) SecretKeyEntry(SecretKeySpec(seed, 0, KEY_SIZE_BYTES, KEY_ALGORITHM_BACKUP))
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) keyStore.setEntry(KEY_ALIAS_BACKUP, backupKeyEntry, getBackupKeyProtection())
} }
override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) && override fun storeMainKey(seed: ByteArray) {
keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java) 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 { 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 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) val builder = KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE_GCM) .setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
@ -70,4 +104,14 @@ internal class KeyManagerImpl : KeyManager {
return builder.build() 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()
}
} }

View file

@ -70,9 +70,12 @@ class RecoveryCodeViewModel(
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
if (forVerifyingNewCode) { if (forVerifyingNewCode) {
keyManager.storeBackupKey(seed) keyManager.storeBackupKey(seed)
keyManager.storeMainKey(seed)
mRecoveryCodeSaved.setEvent(true) mRecoveryCodeSaved.setEvent(true)
} else { } else {
mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed)) val verified = crypto.verifyBackupKey(seed)
if (verified && !keyManager.hasMainKey()) keyManager.storeMainKey(seed)
mExistingCodeChecked.setEvent(verified)
} }
} }

View file

@ -15,12 +15,24 @@ class KeyManagerTestImpl : KeyManager {
throw NotImplementedError("not implemented") throw NotImplementedError("not implemented")
} }
override fun storeMainKey(seed: ByteArray) {
throw NotImplementedError("not implemented")
}
override fun hasBackupKey(): Boolean { override fun hasBackupKey(): Boolean {
return true return true
} }
override fun hasMainKey(): Boolean {
throw NotImplementedError("not implemented")
}
override fun getBackupKey(): SecretKey { override fun getBackupKey(): SecretKey {
return key return key
} }
override fun getMainKey(): SecretKey {
throw NotImplementedError("not implemented")
}
} }

View file

@ -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
)
)
}
}

View file

@ -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)
}
}

View file

@ -8,6 +8,7 @@ import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.TestApp
import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
@ -37,7 +38,10 @@ import kotlin.random.Random
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@RunWith(AndroidJUnit4::class) @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 { class MetadataManagerTest {
private val context: Context = mockk() private val context: Context = mockk()

View file

@ -5,6 +5,7 @@ import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.TestApp
import io.mockk.mockk import io.mockk.mockk
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -15,7 +16,10 @@ import org.koin.core.context.stopKoin
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @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 { internal class DocumentFileTest {
private val context: Context = mockk() private val context: Context = mockk()

View file

@ -6,6 +6,7 @@ import android.util.DisplayMetrics
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.TestApp
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -20,7 +21,10 @@ import org.robolectric.annotation.Config
import kotlin.random.Random import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @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 { internal class DeviceInfoTest {
@After @After