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.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.INTERACT_ACROSS_USERS_FULL` to use storage roots in other users (optional).
## Contributing
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: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
android:name=".App"
android:allowBackup="false"

View file

@ -1,12 +1,16 @@
package com.stevesoltys.seedvault
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
import android.app.Application
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
import android.app.backup.IBackupManager
import android.content.Context
import android.content.Context.BACKUP_SERVICE
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.ServiceManager.getService
import android.os.StrictMode
import android.os.UserHandle
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
@ -138,3 +142,8 @@ fun <T> permitDiskReads(func: () -> T): T {
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.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getSystemContext
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin
import java.io.FileNotFoundException
@ -16,11 +17,15 @@ private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderStoragePlugin(
private val context: Context,
private val appContext: Context,
private val storage: DocumentsStorage,
) : 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)
override suspend fun startNewRestoreSet(token: Long) {

View file

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

View file

@ -5,6 +5,7 @@ import android.hardware.usb.UsbDevice
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.UserHandle
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
@ -121,7 +122,8 @@ class SettingsManager(private val context: Context) {
@WorkerThread
fun canDoBackupNow(): Boolean {
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 {

View file

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

View file

@ -32,7 +32,6 @@ internal class RestoreStorageViewModel(
}
if (hasBackup) {
saveStorage(uri)
mLocationChecked.postEvent(LocationResult())
} else {
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.database.Cursor
import android.graphics.drawable.Drawable
import android.os.UserHandle
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
@ -23,6 +24,8 @@ internal object StorageRootResolver {
private val TAG = StorageRootResolver::class.java.simpleName
private const val usbAuthority = "com.android.externalstorage.documents"
fun getStorageRoots(context: Context, authority: String): List<SafOption> {
val roots = ArrayList<SafOption>()
val rootsUri = DocumentsContract.buildRootsUri(authority)
@ -34,6 +37,16 @@ internal object StorageRootResolver {
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) {
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.MANAGE_USB"/>
<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.MANAGE_EXTERNAL_STORAGE"/>
</privapp-permissions>

View file

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

View file

@ -28,12 +28,18 @@ internal const val CHUNK_FOLDER_COUNT = 256
private const val TAG = "SafStoragePlugin"
/**
* @param appContext application context provided by the storage module
*/
@Suppress("BlockingMethodInNonBlockingContext")
public abstract class SafStoragePlugin(
private val context: Context,
private val appContext: Context,
) : StoragePlugin {
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?
private val folder: DocumentFile?
@ -44,7 +50,7 @@ public abstract class SafStoragePlugin(
@SuppressLint("HardwareIds")
// 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
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
val folderName = "$androidId.sv"
cache.currentFolder = try {
@ -56,8 +62,6 @@ public abstract class SafStoragePlugin(
return cache.currentFolder
}
private val contentResolver = context.contentResolver
private fun timestampToSnapshot(timestamp: Long): String {
return "$timestamp.SeedSnap"
}
@ -153,7 +157,7 @@ public abstract class SafStoragePlugin(
val name = timestampToSnapshot(timestamp)
// TODO should we check if it exists first?
val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE)
return snapshotFile.getOutputStream(contentResolver)
return snapshotFile.getOutputStream(context.contentResolver)
}
/************************* Restore *******************************/
@ -188,7 +192,7 @@ public abstract class SafStoragePlugin(
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
} ?: throw IOException("Could not get file for snapshot $timestamp")
return snapshotFile.getInputStream(contentResolver)
return snapshotFile.getInputStream(context.contentResolver)
}
@Throws(IOException::class)