Allow secondary user backup to USB

By default, Android exposes USB devices only to the main user.
In order to query, read and write to it, the signature permission INTERACT_ACROSS_USERS_FULL (optional) is granted to create Seedvault's context as the system user.

Issue: calyxos#437
Issue: https://github.com/seedvault-app/seedvault/issues/77
Change-Id: I0b1b4c8c5aeeb226419ff94e15f631ebe1db66df
This commit is contained in:
Oliver Scott 2021-06-29 21:33:25 -04:00 committed by Chirayu Desai
parent fa93d5dfcc
commit dd57828697
12 changed files with 67 additions and 16 deletions

View file

@ -43,6 +43,7 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
* `android.permission.FOREGROUND_SERVICE` to do periodic storage backups without interruption. * `android.permission.FOREGROUND_SERVICE` to do periodic storage backups without interruption.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX. * `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX.
* `android.permission.USE_BIOMETRIC` to authenticate saving a new recovery code * `android.permission.USE_BIOMETRIC` to authenticate saving a new recovery code
* `android.permission.INTERACT_ACROSS_USERS_FULL` to use storage roots in other users (optional).
## Contributing ## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault. Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault.

View file

@ -60,6 +60,11 @@
android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" android:name="com.stevesoltys.seedvault.RESTORE_BACKUP"
android:protectionLevel="system|signature" /> android:protectionLevel="system|signature" />
<!-- This is needed to query content providers in other users -->
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS_FULL"
tools:ignore="ProtectedPermissions" />
<application <application
android:name=".App" android:name=".App"
android:allowBackup="false" android:allowBackup="false"

View file

@ -1,12 +1,16 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
import android.app.Application import android.app.Application
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context
import android.content.Context.BACKUP_SERVICE import android.content.Context.BACKUP_SERVICE
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build import android.os.Build
import android.os.ServiceManager.getService import android.os.ServiceManager.getService
import android.os.StrictMode import android.os.StrictMode
import android.os.UserHandle
import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
@ -138,3 +142,8 @@ fun <T> permitDiskReads(func: () -> T): T {
func() func()
} }
} }
fun Context.getSystemContext(isUsbStorage: () -> Boolean): Context {
return if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED
&& isUsbStorage()) createContextAsUser(UserHandle.SYSTEM, 0) else this
}

View file

@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getSystemContext
import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -16,11 +17,15 @@ private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderStoragePlugin( internal class DocumentsProviderStoragePlugin(
private val context: Context, private val appContext: Context,
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
) : StoragePlugin { ) : StoragePlugin {
private val packageManager: PackageManager = context.packageManager private val context: Context get() = appContext.getSystemContext {
storage.storage?.isUsb == true
}
private val packageManager: PackageManager = appContext.packageManager
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) { override suspend fun startNewRestoreSet(token: Long) {

View file

@ -2,6 +2,7 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.database.ContentObserver import android.database.ContentObserver
@ -15,6 +16,7 @@ import android.provider.DocumentsContract.getDocumentId
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getSystemContext
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
@ -40,11 +42,15 @@ const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage( internal class DocumentsStorage(
private val context: Context, private val appContext: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
) { ) {
private val contentResolver = context.contentResolver private val context: Context get() = appContext.getSystemContext {
storage?.isUsb ?: false
}
private val contentResolver: ContentResolver get() = context.contentResolver
internal var storage: Storage? = null internal var storage: Storage? = null
get() { get() {

View file

@ -5,6 +5,7 @@ import android.hardware.usb.UsbDevice
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.UserHandle
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@ -121,7 +122,8 @@ class SettingsManager(private val context: Context) {
@WorkerThread @WorkerThread
fun canDoBackupNow(): Boolean { fun canDoBackupNow(): Boolean {
val storage = getStorage() ?: return false val storage = getStorage() ?: return false
return !storage.isUnavailableUsb(context) && !storage.isUnavailableNetwork(context) return !storage.isUnavailableUsb(context.createContextAsUser(UserHandle.SYSTEM, 0))
&& !storage.isUnavailableNetwork(context)
} }
fun backupApks(): Boolean { fun backupApks(): Boolean {

View file

@ -3,15 +3,20 @@ package com.stevesoltys.seedvault.storage
import android.content.Context import android.content.Context
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getSystemContext
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import javax.crypto.SecretKey import javax.crypto.SecretKey
internal class SeedvaultStoragePlugin( internal class SeedvaultStoragePlugin(
context: Context, private val appContext: Context,
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
private val keyManager: KeyManager, private val keyManager: KeyManager,
) : SafStoragePlugin(context) { ) : SafStoragePlugin(appContext) {
override val context: Context
get() = appContext.getSystemContext {
storage.storage?.isUsb == true
}
override val root: DocumentFile override val root: DocumentFile
get() = storage.rootBackupDir ?: error("No storage set") get() = storage.rootBackupDir ?: error("No storage set")

View file

@ -32,7 +32,6 @@ internal class RestoreStorageViewModel(
} }
if (hasBackup) { if (hasBackup) {
saveStorage(uri) saveStorage(uri)
mLocationChecked.postEvent(LocationResult()) mLocationChecked.postEvent(LocationResult())
} else { } else {
Log.w(TAG, "Location was rejected: $uri") Log.w(TAG, "Location was rejected: $uri")

View file

@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.storage
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.UserHandle
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
@ -23,6 +24,8 @@ internal object StorageRootResolver {
private val TAG = StorageRootResolver::class.java.simpleName private val TAG = StorageRootResolver::class.java.simpleName
private const val usbAuthority = "com.android.externalstorage.documents"
fun getStorageRoots(context: Context, authority: String): List<SafOption> { fun getStorageRoots(context: Context, authority: String): List<SafOption> {
val roots = ArrayList<SafOption>() val roots = ArrayList<SafOption>()
val rootsUri = DocumentsContract.buildRootsUri(authority) val rootsUri = DocumentsContract.buildRootsUri(authority)
@ -34,6 +37,16 @@ internal object StorageRootResolver {
if (root != null) roots.add(root) if (root != null) roots.add(root)
} }
} }
if (usbAuthority == authority && UserHandle.myUserId() != UserHandle.USER_SYSTEM) {
val c: Context = context.createContextAsUser(UserHandle.SYSTEM, 0)
c.contentResolver.query(rootsUri, null, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
// Pass in context since it is used to query package manager for app icons
val root = getStorageRoot(context, authority, cursor)
if (root != null && root.isUsb) roots.add(root)
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to load some roots from $authority", e) Log.w(TAG, "Failed to load some roots from $authority", e)
} }

View file

@ -4,6 +4,7 @@
<permission name="android.permission.BACKUP"/> <permission name="android.permission.BACKUP"/>
<permission name="android.permission.MANAGE_USB"/> <permission name="android.permission.MANAGE_USB"/>
<permission name="android.permission.INSTALL_PACKAGES"/> <permission name="android.permission.INSTALL_PACKAGES"/>
<permission name="android.permission.INTERACT_ACROSS_USERS_FULL"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/> <permission name="android.permission.WRITE_SECURE_SETTINGS"/>
<permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/> <permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
</privapp-permissions> </privapp-permissions>

View file

@ -11,10 +11,11 @@ import javax.crypto.SecretKey
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
class TestSafStoragePlugin( class TestSafStoragePlugin(
private val context: Context, private val appContext: Context,
private val getLocationUri: () -> Uri?, private val getLocationUri: () -> Uri?,
) : SafStoragePlugin(context) { ) : SafStoragePlugin(appContext) {
override val context = appContext
override val root: DocumentFile? override val root: DocumentFile?
get() { get() {
val uri = getLocationUri() ?: return null val uri = getLocationUri() ?: return null

View file

@ -28,12 +28,18 @@ internal const val CHUNK_FOLDER_COUNT = 256
private const val TAG = "SafStoragePlugin" private const val TAG = "SafStoragePlugin"
/**
* @param appContext application context provided by the storage module
*/
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
public abstract class SafStoragePlugin( public abstract class SafStoragePlugin(
private val context: Context, private val appContext: Context,
) : StoragePlugin { ) : StoragePlugin {
private val cache = SafCache() private val cache = SafCache()
// In the case of USB storage, if INTERACT_ACROSS_USERS_FULL is granted, this context will match
// the system user's application context. Otherwise, matches appContext.
protected abstract val context: Context
protected abstract val root: DocumentFile? protected abstract val root: DocumentFile?
private val folder: DocumentFile? private val folder: DocumentFile?
@ -44,7 +50,7 @@ public abstract class SafStoragePlugin(
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
// this is unique to each combination of app-signing key, user, and device // this is unique to each combination of app-signing key, user, and device
// so we don't leak anything by not hashing this and can use it as is // so we don't leak anything by not hashing this and can use it as is
val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID)
// the folder name is our user ID // the folder name is our user ID
val folderName = "$androidId.sv" val folderName = "$androidId.sv"
cache.currentFolder = try { cache.currentFolder = try {
@ -56,8 +62,6 @@ public abstract class SafStoragePlugin(
return cache.currentFolder return cache.currentFolder
} }
private val contentResolver = context.contentResolver
private fun timestampToSnapshot(timestamp: Long): String { private fun timestampToSnapshot(timestamp: Long): String {
return "$timestamp.SeedSnap" return "$timestamp.SeedSnap"
} }
@ -153,7 +157,7 @@ public abstract class SafStoragePlugin(
val name = timestampToSnapshot(timestamp) val name = timestampToSnapshot(timestamp)
// TODO should we check if it exists first? // TODO should we check if it exists first?
val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE) val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE)
return snapshotFile.getOutputStream(contentResolver) return snapshotFile.getOutputStream(context.contentResolver)
} }
/************************* Restore *******************************/ /************************* Restore *******************************/
@ -188,7 +192,7 @@ public abstract class SafStoragePlugin(
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) { val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp)) getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
} ?: throw IOException("Could not get file for snapshot $timestamp") } ?: throw IOException("Could not get file for snapshot $timestamp")
return snapshotFile.getInputStream(contentResolver) return snapshotFile.getInputStream(context.contentResolver)
} }
@Throws(IOException::class) @Throws(IOException::class)