Split key concern out of storage plugin

This creates a KeyManager interface in the new core module which the storage module can use to get the key from.
This commit is contained in:
Torsten Grote 2024-08-23 17:35:40 -03:00
parent 9e56384cb2
commit 27eb95f768
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
21 changed files with 64 additions and 72 deletions

View file

@ -24,7 +24,7 @@ internal 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 : org.calyxos.seedvault.core.crypto.KeyManager {
/**
* Store a new backup key derived from the given [seed].
*
@ -57,14 +57,6 @@ interface KeyManager {
* 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(

View file

@ -8,12 +8,10 @@ package com.stevesoltys.seedvault.plugins.webdav
import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.StoragePlugin
class WebDavFactory(
private val context: Context,
private val keyManager: KeyManager,
) {
fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> {
@ -27,7 +25,6 @@ class WebDavFactory(
val androidId =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
return com.stevesoltys.seedvault.storage.WebDavStoragePlugin(
keyManager = keyManager,
androidId = androidId,
webDavConfig = config,
)

View file

@ -9,6 +9,6 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val storagePluginModuleWebDav = module {
single { WebDavFactory(androidContext(), get()) }
single { WebDavFactory(androidContext()) }
single { WebDavHandler(androidContext(), get(), get(), get()) }
}

View file

@ -11,7 +11,6 @@ import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import javax.crypto.SecretKey
internal class SeedvaultSafStoragePlugin(
private val appContext: Context,
@ -24,6 +23,4 @@ internal class SeedvaultSafStoragePlugin(
override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set")
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
}

View file

@ -5,10 +5,11 @@
package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import org.calyxos.backup.storage.api.StorageBackup
import org.koin.dsl.module
val storageModule = module {
single { StorageBackup(get(), { get<StoragePluginManager>().filesPlugin }) }
single { StorageBackup(get(), { get<StoragePluginManager>().filesPlugin }, get<KeyManager>()) }
}

View file

@ -11,7 +11,6 @@ import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
@ -26,12 +25,10 @@ import org.koin.core.time.measureDuration
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.SecretKey
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin(
private val keyManager: KeyManager,
/**
* The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
*/
@ -121,9 +118,6 @@ internal class WebDavStoragePlugin(
}
}
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
@Throws(IOException::class)
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
val chunkFolderName = chunkId.substring(0, 2)

View file

@ -5,12 +5,10 @@
package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
import com.stevesoltys.seedvault.transport.backup.BackupTest
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.calyxos.backup.storage.api.StoredSnapshot
import org.junit.Assert.assertArrayEquals
@ -21,8 +19,7 @@ import java.io.IOException
internal class WebDavStoragePluginTest : BackupTest() {
private val keyManager: KeyManager = mockk()
private val plugin = WebDavStoragePlugin(keyManager, "foo", WebDavTestConfig.getConfig())
private val plugin = WebDavStoragePlugin("foo", WebDavTestConfig.getConfig())
private val snapshot = StoredSnapshot("foo.sv", System.currentTimeMillis())
@ -85,7 +82,7 @@ internal class WebDavStoragePluginTest : BackupTest() {
)
// other device writes another snapshot
val otherPlugin = WebDavStoragePlugin(keyManager, "bar", WebDavTestConfig.getConfig())
val otherPlugin = WebDavStoragePlugin("bar", WebDavTestConfig.getConfig())
val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis())
val otherSnapshotBytes = getRandomByteArray()
assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds())
@ -110,7 +107,6 @@ internal class WebDavStoragePluginTest : BackupTest() {
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(
keyManager = keyManager,
androidId = "foo",
webDavConfig = WebDavTestConfig.getConfig(),
root = getRandomString(),

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.seedvault.core.crypto
import java.security.KeyStore
import javax.crypto.SecretKey
public interface KeyManager {
/**
* 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.
*/
public fun getMainKey(): SecretKey
}

View file

@ -69,6 +69,7 @@ android {
}
dependencies {
implementation(project(":core"))
implementation(project(":storage:lib"))
implementation(libs.bundles.kotlin)

View file

@ -9,6 +9,7 @@ import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.VmPolicy
import android.util.Log
import de.grobox.storagebackuptester.crypto.KeyManager
import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin
import de.grobox.storagebackuptester.settings.SettingsManager
import org.calyxos.backup.storage.api.StorageBackup
@ -19,7 +20,7 @@ class App : Application() {
val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) }
val storageBackup: StorageBackup by lazy {
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
StorageBackup(this, { plugin })
StorageBackup(this, { plugin }, KeyManager)
}
val fileSelectionManager: FileSelectionManager get() = FileSelectionManager()

View file

@ -24,7 +24,7 @@ class MainActivity : AppCompatActivity() {
KeyManager.storeMasterKey()
if (!KeyManager.hasMasterKey()) {
if (!KeyManager.hasMainKey()) {
Log.e("TEST", "storing new key")
KeyManager.storeMasterKey()
} else {

View file

@ -14,7 +14,7 @@ import java.security.KeyStore
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
object KeyManager {
object KeyManager: org.calyxos.seedvault.core.crypto.KeyManager {
private const val KEY_SIZE = 256
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
@ -42,9 +42,9 @@ object KeyManager {
keyStore.setEntry(KEY_ALIAS_MASTER, ksEntry, getKeyProtection())
}
fun hasMasterKey(): Boolean = keyStore.containsAlias(KEY_ALIAS_MASTER)
fun hasMainKey(): Boolean = keyStore.containsAlias(KEY_ALIAS_MASTER)
fun getMasterKey(): SecretKey {
override fun getMainKey(): SecretKey {
val ksEntry = keyStore.getEntry(KEY_ALIAS_MASTER, null) as KeyStore.SecretKeyEntry
return ksEntry.secretKey
}

View file

@ -8,13 +8,10 @@ package de.grobox.storagebackuptester.plugin
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import de.grobox.storagebackuptester.crypto.KeyManager
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import java.io.IOException
import java.io.OutputStream
import javax.crypto.SecretKey
@Suppress("BlockingMethodInNonBlockingContext")
class TestSafStoragePlugin(
appContext: Context,
private val getLocationUri: () -> Uri?,
@ -33,14 +30,6 @@ class TestSafStoragePlugin(
}
}
override fun getMasterKey(): SecretKey {
return KeyManager.getMasterKey()
}
override fun hasMasterKey(): Boolean {
return KeyManager.hasMasterKey()
}
@Throws(IOException::class)
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
if (getLocationUri() == null) return nullStream

View file

@ -81,6 +81,7 @@ android {
}
dependencies {
implementation(project(":core"))
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.fragment)

View file

@ -31,6 +31,7 @@ import org.calyxos.backup.storage.scanner.DocumentScanner
import org.calyxos.backup.storage.scanner.FileScanner
import org.calyxos.backup.storage.scanner.MediaScanner
import org.calyxos.backup.storage.toStoredUri
import org.calyxos.seedvault.core.crypto.KeyManager
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
@ -39,6 +40,7 @@ private const val TAG = "StorageBackup"
public class StorageBackup(
private val context: Context,
private val pluginGetter: () -> StoragePlugin,
private val keyManager: KeyManager,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
@ -54,13 +56,16 @@ public class StorageBackup(
private val backup by lazy {
val documentScanner = DocumentScanner(context)
val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner)
Backup(context, db, fileScanner, pluginGetter, chunksCacheRepopulater)
Backup(context, db, fileScanner, pluginGetter, keyManager, chunksCacheRepopulater)
}
private val restore by lazy {
Restore(context, pluginGetter, snapshotRetriever, FileRestore(context, mediaScanner))
val fileRestore = FileRestore(context, mediaScanner)
Restore(context, pluginGetter, keyManager, snapshotRetriever, fileRestore)
}
private val retention = RetentionManager(context)
private val pruner by lazy { Pruner(db, retention, pluginGetter, snapshotRetriever) }
private val pruner by lazy {
Pruner(db, retention, pluginGetter, keyManager, snapshotRetriever)
}
private val backupRunning = AtomicBoolean(false)
private val restoreRunning = AtomicBoolean(false)

View file

@ -8,8 +8,6 @@ package org.calyxos.backup.storage.api
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.KeyStore
import javax.crypto.SecretKey
public interface StoragePlugin {
@ -28,16 +26,6 @@ public interface StoragePlugin {
@Throws(IOException::class)
public suspend fun getAvailableChunkIds(): List<String>
/**
* Returns a [SecretKey] for HmacSHA256, ideally stored in the [KeyStore].
*/
public fun getMasterKey(): SecretKey
/**
* Returns true if the key for [getMasterKey] exists, false otherwise.
*/
public fun hasMasterKey(): Boolean
@Throws(IOException::class)
public suspend fun getChunkOutputStream(chunkId: String): OutputStream
@ -48,8 +36,7 @@ public interface StoragePlugin {
/**
* Returns *all* [StoredSnapshot]s that are available on storage
* independent of user ID and whether they can be decrypted
* with the key returned by [getMasterKey].
* independent of user ID and whether they can be decrypted with the main key.
*/
@Throws(IOException::class)
public suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot>

View file

@ -19,6 +19,7 @@ import org.calyxos.backup.storage.db.Db
import org.calyxos.backup.storage.measure
import org.calyxos.backup.storage.scanner.FileScanner
import org.calyxos.backup.storage.scanner.FileScannerResult
import org.calyxos.seedvault.core.crypto.KeyManager
import java.io.IOException
import java.security.GeneralSecurityException
import kotlin.time.Duration
@ -42,6 +43,7 @@ internal class Backup(
private val db: Db,
private val fileScanner: FileScanner,
private val storagePluginGetter: () -> StoragePlugin,
keyManager: KeyManager,
private val cacheRepopulater: ChunksCacheRepopulater,
chunkSizeMax: Int = CHUNK_SIZE_MAX,
private val streamCrypto: StreamCrypto = StreamCrypto,
@ -60,12 +62,12 @@ internal class Backup(
private val chunksCache = db.getChunksCache()
private val mac = try {
ChunkCrypto.getMac(ChunkCrypto.deriveChunkIdKey(storagePlugin.getMasterKey()))
ChunkCrypto.getMac(ChunkCrypto.deriveChunkIdKey(keyManager.getMainKey()))
} catch (e: GeneralSecurityException) {
throw AssertionError(e)
}
private val streamKey = try {
streamCrypto.deriveStreamKey(storagePlugin.getMasterKey())
streamCrypto.deriveStreamKey(keyManager.getMainKey())
} catch (e: GeneralSecurityException) {
throw AssertionError(e)
}

View file

@ -13,6 +13,7 @@ import org.calyxos.backup.storage.crypto.StreamCrypto
import org.calyxos.backup.storage.db.Db
import org.calyxos.backup.storage.measure
import org.calyxos.backup.storage.plugin.SnapshotRetriever
import org.calyxos.seedvault.core.crypto.KeyManager
import java.io.IOException
import java.security.GeneralSecurityException
import kotlin.time.ExperimentalTime
@ -23,6 +24,7 @@ internal class Pruner(
private val db: Db,
private val retentionManager: RetentionManager,
private val storagePluginGetter: () -> StoragePlugin,
keyManager: KeyManager,
private val snapshotRetriever: SnapshotRetriever,
streamCrypto: StreamCrypto = StreamCrypto,
) {
@ -30,7 +32,7 @@ internal class Pruner(
private val storagePlugin get() = storagePluginGetter()
private val chunksCache = db.getChunksCache()
private val streamKey = try {
streamCrypto.deriveStreamKey(storagePlugin.getMasterKey())
streamCrypto.deriveStreamKey(keyManager.getMainKey())
} catch (e: GeneralSecurityException) {
throw AssertionError(e)
}

View file

@ -19,6 +19,7 @@ import org.calyxos.backup.storage.backup.BackupSnapshot
import org.calyxos.backup.storage.crypto.StreamCrypto
import org.calyxos.backup.storage.measure
import org.calyxos.backup.storage.plugin.SnapshotRetriever
import org.calyxos.seedvault.core.crypto.KeyManager
import java.io.IOException
import java.io.InputStream
import java.security.GeneralSecurityException
@ -28,6 +29,7 @@ private const val TAG = "Restore"
internal class Restore(
context: Context,
private val storagePluginGetter: () -> StoragePlugin,
private val keyManager: KeyManager,
private val snapshotRetriever: SnapshotRetriever,
fileRestore: FileRestore,
streamCrypto: StreamCrypto = StreamCrypto,
@ -39,7 +41,7 @@ internal class Restore(
// so we need to get it lazily here to prevent crashes. We can still crash later,
// if the plugin is not providing a key as it should when performing calls into this class.
try {
streamCrypto.deriveStreamKey(storagePlugin.getMasterKey())
streamCrypto.deriveStreamKey(keyManager.getMainKey())
} catch (e: GeneralSecurityException) {
throw AssertionError(e)
}

View file

@ -46,6 +46,7 @@ import org.calyxos.backup.storage.restore.RestorableFile
import org.calyxos.backup.storage.restore.Restore
import org.calyxos.backup.storage.scanner.FileScanner
import org.calyxos.backup.storage.scanner.FileScannerResult
import org.calyxos.seedvault.core.crypto.KeyManager
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@ -71,6 +72,7 @@ internal class BackupRestoreTest {
private val fileScanner: FileScanner = mockk()
private val pluginGetter: () -> StoragePlugin = mockk()
private val keyManager: KeyManager = mockk()
private val plugin: StoragePlugin = mockk()
private val fileRestore: FileRestore = mockk()
private val snapshotRetriever = SnapshotRetriever(pluginGetter)
@ -87,7 +89,7 @@ internal class BackupRestoreTest {
every { pluginGetter() } returns plugin
every { db.getFilesCache() } returns filesCache
every { db.getChunksCache() } returns chunksCache
every { plugin.getMasterKey() } returns SecretKeySpec(
every { keyManager.getMainKey() } returns SecretKeySpec(
"This is a backup key for testing".toByteArray(),
0, KEY_SIZE_BYTES, ALGORITHM_HMAC
)
@ -95,11 +97,11 @@ internal class BackupRestoreTest {
every { context.contentResolver } returns contentResolver
}
private val restore = Restore(context, pluginGetter, snapshotRetriever, fileRestore)
private val restore = Restore(context, pluginGetter, keyManager, snapshotRetriever, fileRestore)
@Test
fun testZipAndSingleRandom(): Unit = runBlocking {
val backup = Backup(context, db, fileScanner, pluginGetter, cacheRepopulater)
val backup = Backup(context, db, fileScanner, pluginGetter, keyManager, cacheRepopulater)
val smallFileMBytes = Random.nextBytes(Random.nextInt(SMALL_FILE_SIZE_MAX))
val smallFileM = getRandomMediaFile(smallFileMBytes.size)
@ -236,7 +238,8 @@ internal class BackupRestoreTest {
@Test
fun testMultiChunks(): Unit = runBlocking {
val backup = Backup(context, db, fileScanner, pluginGetter, cacheRepopulater, 4)
val backup =
Backup(context, db, fileScanner, pluginGetter, keyManager, cacheRepopulater, 4)
val chunk1 = byteArrayOf(0x00, 0x01, 0x02, 0x03)
val chunk2 = byteArrayOf(0x04, 0x05, 0x06, 0x07)

View file

@ -26,6 +26,7 @@ import org.calyxos.backup.storage.db.Db
import org.calyxos.backup.storage.getRandomString
import org.calyxos.backup.storage.mockLog
import org.calyxos.backup.storage.plugin.SnapshotRetriever
import org.calyxos.seedvault.core.crypto.KeyManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@ -37,6 +38,7 @@ internal class PrunerTest {
private val db: Db = mockk()
private val chunksCache: ChunksCache = mockk()
private val pluginGetter: () -> StoragePlugin = mockk()
private val keyManager: KeyManager = mockk()
private val plugin: StoragePlugin = mockk()
private val snapshotRetriever: SnapshotRetriever = mockk()
private val retentionManager: RetentionManager = mockk()
@ -48,11 +50,12 @@ internal class PrunerTest {
mockLog(false)
every { pluginGetter() } returns plugin
every { db.getChunksCache() } returns chunksCache
every { plugin.getMasterKey() } returns masterKey
every { keyManager.getMainKey() } returns masterKey
every { streamCrypto.deriveStreamKey(masterKey) } returns streamKey
}
private val pruner = Pruner(db, retentionManager, pluginGetter, snapshotRetriever, streamCrypto)
private val pruner =
Pruner(db, retentionManager, pluginGetter, keyManager, snapshotRetriever, streamCrypto)
@Test
fun test() = runBlocking {