Приходилось ли вам браться за задачу, из-за которой прошлый разработчик успел выгореть и сменить компанию? Что ж, мне удалось с такой столкнуться — c задачей обеспечения безопасного локального хранения файлов, которые пользователь загружает в приложение, например, общаясь с технической поддержкой в чате. Обо всех деталях и во всех подробностях я и расскажу в данной статье.
Получаем файл от пользователя
Предоставить возможность пользователю выбрать документ для загрузки можно разными способами, например, создав intent через Intent.createChooser с интересующим нас action Intent.ACTION_OPEN_DOCUMENT и прочими необходимыми параметрами для вашего конкретного случая. Система временно выдаст нам доступ к файлу по URI.
Пример такого URI:
content:/com.android.providers.downloads.documents/document/msf:1000000019
В отличии от обычного пути до файла в системе, например, /data/user/0/com.mobile.android/cache/cache_files/Test.pdf , где мы можем просто завернуть путь в File , сформировав через File.asRequestBody Body и положить в MultipartBody.Part для отправки на сервер, для работы с URI со схемой content нужно создать кастомный RequestBody, в BufferedSink которого во время запроса будет записан InputStream нашего файла, полученного по URI через contentResolver:
fun createRequestBodyFromUri(uri: Uri): RequestBody { return object : RequestBody() { override fun contentType(): MediaType = uriInfoProvider.getMimeType(uri) override fun contentLength(): Long = uriInfoProvider.getFileSize(uri) override fun writeTo(sink: BufferedSink) { val input = context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream for uri=$uri") input.use { sink.writeAll(it.source()) } } } }
Я периодически вижу неоптимальные решения разработчиков, где создаются временные файлы без особой необходимости. Но что хуже, ещё чаще эти самые временные файлы не чистятся в принципе. Подход выше избавляет нас от такой необходимости, если иное не указано в бизнес-требованиях к фиче.
Далее нам предстоит разобраться с локальным сохранением данных.
Сохранение в зашифрованном формате
В качестве библиотеки шифрования используется androidx.security.crypto и EncryptedFile из неё. Небольшой пример по созданию зашифрованного файла:
val file = File(context.getFilesDir(), "sensitive_data") val encryptedFile = EncryptedFile.Builder( file, context, MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB ).build()
После чего мы можем использовать encryptedFile для записи в него или чтения:
// write to the encrypted file val encryptedOutputStream = encryptedFile.openFileOutput() // read the encrypted file val encryptedInputStream = encryptedFile.openFileInput()
Важной особенностью является необходимость удаления файла перед записью зашифрованных данных. В существующий файл ничего записать не получится.
Если мы будем сразу же использовать расшифрованный файл, например, для воспроизведения его в MediaPlayer , то трудностей не возникнет. Но что, если открывать наш зашифрованный файл должно стороннее системное приложение для просмотра документов?
Как стороннее приложение может безопасно прочитать ваш зашифрованный файл
Если ваш файл должен открываться сторонними приложениями, то вам потребуется реализовать безопасный механизм предоставления ваших зашифрованных данных стороннему приложению-потребителю. Для это можно реализовать кастомный ContentProvider, который сумеет обработать ваш новый URI, и сможет временно выдавать стороннему приложению доступ к файлу по этому URI.
А теперь к реализации. Объявим в Manifest ContentProvider:
<provider android:name=".provider.FileDecryptionContentProvider" android:authorities="${applicationId}.filedecryptionrpovider" android:exported="false" android:grantUriPermissions="true" />
Параметр grantUriPermissions обязателен, чтобы стороннее приложение могло получить доступы к вашим данным, только если инициирующий открытие файла Intent имел нужные разрешения, которые вы явно должны передать. Далее объявляем наш ContentProvider:
class FileDecryptionContentProvider : ContentProvider() { // ... override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { if (mode != "r") throw SecurityException("Only read mode supported") val file = resolveFileFromUri(uri) if (!file.exists()) throw FileNotFoundException("File not found: $file") val storageMode = detectStorageMode(file) return openFile(file, storageMode) } // ... }
Внедрение подобного алгоритма шифрования, может вызывать желание использовать ContentProvider не только для чувствительных зашифрованных данных, но и для обычных. В этом случае потребуется механизм для определения того, является ли открываемый URI зашифрованным или его можно просто открыть. В целях безопасности не буду раскрывать свою реализацию, но думаю, что вы сможете придумать решение для вашего конкретного проекта, учитывая, что URI для открытия файла и тот, что приходит в openFile, будете формировать вы сами.
Но что же такое ParcelFileDescriptor и как его создать для чтения файла?
Создание ParcelFileDescriptor
Если хотите отдать на чтение стороннему процессу незашифрованный файл, достаточно вызова ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) . Для зашифрованного файла ситуация интереснее, поскольку после расшифровки мы получаем InputStream , которые нельзя так просто записать в ParcelFileDescriptor.createPipe() и вернуть из метода openFile — это была моя первая попытка, которая не сработала:
val pipe = ParcelFileDescriptor.createPipe() val readSide = pipe[0] val writeSide = pipe[1] ioExecutor.execute { ParcelFileDescriptor.AutoCloseOutputStream(writeSide).use { out -> encryptingManager.openInputStream(encryptedFile).use { input -> input.copyTo(out) } } } return readSide
На этом моменте я даже немного расстроился, ведь всё шло так гладко, а судьбу прошлого разработчика повторять не хочется, как и переделы��ать всё решение.
Но я не сдался и нашел свое спасение в перегруженном методе ParcelFileDescriptor.open , в который можно передать коллбек, срабатывающий, как только сторонний процесс закроет дескриптор. Логика начала выглядеть примерно так:
val tempFile = File.createTempFile( "temp_$encryptedFileName", encryptedFileExtension, parentDir, ) encryptingManager.openInputStream(encryptedFile).use { input -> FileOutputStream(tempFile).use { output -> input.copyTo(output) } } ParcelFileDescriptor.open( tempFile, ParcelFileDescriptor.MODE_READ_ONLY, Handler(Looper.getMainLooper()) ) { tempFile.delete() }
После того, как логика по предоставлению внешнему приложений файла реализована, давайте посмотрим, как создать и открыть URI, который будет вызывать наш FileDecryptionContentProvider
Создание корректного URI для FileDecryptionContentProvider
Для создания корректного URI вы можете воспользоваться билдером:
Uri.Builder() .scheme("content") .authority("${context.packageName}.filedecryptionrpovider") // here you can specify all params to retrieve file later .build()
Далее создаём Intent с необходимыми разрешениями и ранее сформированным URI (определяет приложение, которое будет читать наш файл):
Intent(Intent.ACTION_VIEW).apply { setDataAndTypeAndNormalize(uri, mimeType) flags = Intent.FLAG_GRANT_READ_URI_PERMISSION }
Создаём chooser Intent (чтобы всегда показывать пользователю диалог выбора приложения, хотя это не обязательно, но безопаснее) и запускаем активность.
val chooserIntent = Intent.createChooser(targetIntent, chooserTitle).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } context.startActivity(chooserIntent)
После этого пользователь сможет выбрать приложения для открытия файла и просмотра его содержимого.
Рефлексия шифрования
Необходимость локального хранения чувствительных данных пользователя в общем случае является спорным решением. Ведь данные, действительно являющиеся PII (Personally Identifiable Information), возможно всегда стоит грузить с сервера, не допуская кеширования. А если кеширование по той или иной причине все-таки нужно, то может вообще не шифровать данные? Посколько они сохраняются в закрытом sandbox-приложении, а при нарушении целостности устройства (через использование root) сам EncryptedFile, возможно, перестанет быть настолько надежным.
«За» и «Против» в этом вопросе достаточно. Однако, вне зависимости от них, иногда решение может приниматься регулирующим органом вашей организации или страны и требовать шифрования, даже если мне, как разработчику, не до конца понятно, зачем.
А что думаете вы по этому поводу, всегда ли храните локальные данные в зашифрованном формате?
Подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.
Читайте также:

