Skip to content

Commit f87f9e1

Browse files
committed
fix(migration): chunked 0x03 GCM format to fix export OOM
Whole-file AES-GCM decryption buffers an entire ciphertext internally (SunJCE), so importing a large encrypted export could OOM the app; the export side held the full plaintext and ciphertext in memory. Introduce a version 0x03 chunked-GCM format: - one PBKDF2 key, 8 MiB plaintext chunks, per-chunk counter IVs (base IV XOR chunk index in bytes 4..11), per-chunk GCM tag - encryptFile/encryptToFile stream in both directions (bounded memory) - decryptFile/decryptStream decrypt chunk by chunk (~2x chunk peak) - 0x02 legacy files still import (whole-file path, unchanged behavior) Import now stages the bundle as a local plaintext ZIP in the app cache instead of holding zipBytes in memory: preview copies the file (no full read), the manifest parses via a streaming JSON decoder, and import re-streams the staged file for attachments. The staged file is owned by the caller and removed in finally/onCleared/init cleanup. Verified: 120 MiB export decrypts under a 90 MiB heap in the functional test; MigrationCryptoTest (19), MigrationImporterEncryptionTest (8), and MigrationViewModelTest (20) all pass; ktlint + detekt clean.
1 parent fc086dc commit f87f9e1

7 files changed

Lines changed: 674 additions & 162 deletions

File tree

app/src/main/java/network/columba/app/migration/MigrationCrypto.kt

Lines changed: 384 additions & 35 deletions
Large diffs are not rendered by default.

app/src/main/java/network/columba/app/migration/MigrationData.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package network.columba.app.migration
22

33
import network.columba.app.data.db.entity.CustomThemeEntity
44
import kotlinx.serialization.Serializable
5+
import java.io.File
56

67
/**
78
* Migration bundle containing all exportable app data.
@@ -430,12 +431,13 @@ data class MigrationPreview(
430431
)
431432

432433
/**
433-
* Result of previewing a migration file, including the decrypted ZIP bytes
434-
* so they can be reused during import without redundant decryption.
434+
* Result of previewing a migration file, including the local copy of the
435+
* bundle so the import can re-stream it without redundant key derivation
436+
* or re-copying from the content resolver.
435437
*/
436438
class PreviewWithData(
437439
val preview: MigrationPreview,
438-
val zipBytes: ByteArray,
440+
val file: File,
439441
)
440442

441443
/**

app/src/main/java/network/columba/app/migration/MigrationImporter.kt

Lines changed: 131 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
1010
import kotlinx.coroutines.flow.first
1111
import kotlinx.coroutines.withContext
1212
import kotlinx.serialization.json.Json
13+
import kotlinx.serialization.json.decodeFromStream
1314
import network.columba.app.data.crypto.IdentityKeyEncryptor
1415
import network.columba.app.data.crypto.WrongPasswordException
1516
import network.columba.app.data.database.InterfaceDatabase
@@ -56,6 +57,23 @@ class MigrationImporter
5657
private const val TAG = "MigrationImporter"
5758
private const val MANIFEST_FILENAME = "manifest.json"
5859
private const val ATTACHMENTS_PREFIX = "attachments/"
60+
/**
61+
* Staged import bundle files (local plaintext ZIPs) live here
62+
* while an import is in progress; [importData] removes the staged
63+
* file in a finally block.
64+
*/
65+
const val IMPORT_CACHE_DIR = "migration_import"
66+
}
67+
68+
/**
69+
* Delete staged import bundles left behind by a cancelled preview or a
70+
* crash. Safe: the directory is written only by this importer during
71+
* its import/preview flow.
72+
*/
73+
fun cleanupStagedImports() {
74+
File(context.cacheDir, IMPORT_CACHE_DIR).listFiles()?.forEach { file ->
75+
if (file.isFile) file.delete()
76+
}
5977
}
6078

6179
private val json =
@@ -91,13 +109,25 @@ class MigrationImporter
91109
}
92110
}
93111

112+
/**
113+
* Preview a migration file before importing.
114+
*
115+
* The bundle is copied to the cache directory (and decrypted there if
116+
* needed) so the manifest can be parsed in a streaming pass and the
117+
* import can reuse the same local file without re-reading through the
118+
* content resolver or holding the bundle in memory.
119+
*
120+
* Ownership of the staged file transfers to the caller (the
121+
* ViewModel), which deletes it when the import completes, the state
122+
* resets, or the ViewModel is cleared.
123+
*/
94124
suspend fun previewMigration(
95125
uri: Uri,
96126
password: String? = null,
97127
): Result<PreviewWithData> =
98128
withContext(Dispatchers.IO) {
99129
try {
100-
val (bundle, zipBytes) =
130+
val (bundle, localFile) =
101131
readMigrationBundle(uri, password)
102132
?: return@withContext Result.failure(
103133
Exception("Failed to read migration file"),
@@ -120,7 +150,7 @@ class MigrationImporter
120150
callHistoryCount = bundle.callHistory.size,
121151
identityNames = bundle.identities.map { it.displayName },
122152
),
123-
zipBytes = zipBytes,
153+
file = localFile,
124154
),
125155
)
126156
} catch (e: Exception) {
@@ -129,15 +159,6 @@ class MigrationImporter
129159
}
130160
}
131161

132-
/**
133-
* Check if an import file requires a password.
134-
*/
135-
suspend fun requiresPassword(uri: Uri): Boolean =
136-
withContext(Dispatchers.IO) {
137-
val (bundle, _) = readMigrationBundle(uri) ?: return@withContext false
138-
bundle.keysEncrypted
139-
}
140-
141162
/**
142163
* Import data from a migration bundle file.
143164
*
@@ -149,57 +170,77 @@ class MigrationImporter
149170
suspend fun importData(
150171
uri: Uri,
151172
password: String? = null,
152-
cachedZipBytes: ByteArray? = null,
173+
cachedZipFile: File? = null,
153174
onProgress: (Float) -> Unit = {},
154175
importPassword: CharArray? = null,
155176
): ImportResult =
156177
withContext(Dispatchers.IO) {
178+
Log.i(TAG, "Starting migration import...")
179+
onProgress(0.05f)
180+
181+
// Stage the bundle as a local plaintext ZIP: reuse the file
182+
// prepared by the preview when available (it is already
183+
// decrypted), otherwise copy + decrypt from the URI. The
184+
// manifest is parsed in a single streaming pass either way.
185+
val (bundle, stagedFile, importerOwnedFile) =
186+
if (cachedZipFile != null && cachedZipFile.exists()) {
187+
// Reuse the preview's local bundle (already a plaintext
188+
// ZIP). The caller still owns this file, so do not
189+
// delete it here.
190+
val parsed = streamBundleFromZipFile(cachedZipFile)
191+
?: return@withContext ImportResult.Error(
192+
"Failed to read migration file",
193+
)
194+
Triple(parsed.first, parsed.second, false)
195+
} else {
196+
val parsed = readMigrationBundle(uri, password)
197+
?: return@withContext ImportResult.Error(
198+
"Failed to read migration file",
199+
)
200+
Triple(parsed.first, parsed.second, true)
201+
}
202+
157203
try {
158-
Log.i(TAG, "Starting migration import...")
159-
onProgress(0.05f)
160-
161-
val (bundle, zipBytes) =
162-
if (cachedZipBytes != null) {
163-
// Reuse decrypted bytes from preview to avoid redundant PBKDF2 + decryption
164-
val manifestJson =
165-
extractManifestFromZip(java.io.ByteArrayInputStream(cachedZipBytes))
166-
val parsed =
167-
manifestJson?.let { json.decodeFromString<MigrationBundle>(it) }
168-
?: return@withContext ImportResult.Error(
169-
"Failed to read migration file",
170-
)
171-
parsed to cachedZipBytes
172-
} else {
173-
readMigrationBundle(uri, password)
174-
?: return@withContext ImportResult.Error(
175-
"Failed to read migration file",
176-
)
177-
}
204+
importDataInternal(bundle, stagedFile, onProgress, importPassword)
205+
} finally {
206+
// Only remove files this importer created; the caller
207+
// (ViewModel) owns and re-cleans the preview's cache file.
208+
if (importerOwnedFile) stagedFile.delete()
209+
}
210+
}
178211

212+
@Suppress("LongMethod", "ComplexMethod", "ReturnCount")
213+
private suspend fun importDataInternal(
214+
bundle: MigrationBundle,
215+
stagedFile: File,
216+
onProgress: (Float) -> Unit,
217+
importPassword: CharArray?,
218+
): ImportResult {
219+
return try {
179220
if (bundle.version > MigrationBundle.CURRENT_VERSION) {
180-
return@withContext ImportResult.Error(
221+
return ImportResult.Error(
181222
"Migration file is from a newer version (${bundle.version}). " +
182223
"Please update the app first.",
183224
)
184225
}
185226

186227
// Check minimum supported version for backwards compatibility
187228
if (bundle.version < MigrationBundle.MINIMUM_VERSION) {
188-
return@withContext ImportResult.Error(
229+
return ImportResult.Error(
189230
"Migration file is from an old version (${bundle.version}). " +
190231
"Minimum supported version is ${MigrationBundle.MINIMUM_VERSION}.",
191232
)
192233
}
193234

194235
if (bundle.version < 8 && bundle.callHistoryDeletions.isNotEmpty()) {
195-
return@withContext ImportResult.Error(
236+
return ImportResult.Error(
196237
"Call-history deletion authority requires migration format version 8.",
197238
)
198239
}
199240

200241
// Check if password is required but not provided
201242
if (bundle.keysEncrypted && importPassword == null) {
202-
return@withContext ImportResult.Error(
243+
return ImportResult.Error(
203244
"This export file is password-protected. Please provide the password.",
204245
)
205246
}
@@ -218,7 +259,7 @@ class MigrationImporter
218259
val interfacesImported = importInterfaces(bundle.interfaces)
219260
onProgress(0.86f)
220261

221-
if (bundle.attachmentManifest.isNotEmpty()) importAttachments(zipBytes)
262+
if (bundle.attachmentManifest.isNotEmpty()) importAttachments(stagedFile)
222263
onProgress(0.90f)
223264

224265
importRatchets(bundle.ratchetFiles)
@@ -675,33 +716,55 @@ class MigrationImporter
675716
)
676717

677718
/**
678-
* Read and parse the MigrationBundle from a ZIP file.
719+
* Read the migration bundle from a URI, preparing a local cache file
720+
* for the rest of the import flow.
721+
*
722+
* The file is copied to the cache directory via [File.copyTo] (no
723+
* full in-memory read), encrypted files are decrypted in-place with a
724+
* streaming chunked pass, and the manifest is parsed with a streaming
725+
* JSON decoder (no full JSON String). Returns null on unreadable input;
726+
* password errors propagate to the caller.
679727
*/
680728
@Suppress("ThrowsCount")
681729
private fun readMigrationBundle(
682730
uri: Uri,
683731
password: String? = null,
684-
): Pair<MigrationBundle, ByteArray>? {
732+
): Pair<MigrationBundle, File>? {
685733
return try {
686734
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
687-
inputStream.use { stream ->
688-
val rawBytes = stream.readBytes()
689-
val zipBytes =
690-
if (MigrationCrypto.isEncrypted(rawBytes)) {
691-
if (password == null) {
692-
throw PasswordRequiredException("This export file is encrypted")
735+
val cacheDir = File(context.cacheDir, IMPORT_CACHE_DIR).also { it.mkdirs() }
736+
val localFile = File(cacheDir, "columba_import_${System.currentTimeMillis()}.columba")
737+
try {
738+
inputStream.use { stream -> stream.copyTo(localFile.outputStream()) }
739+
} catch (e: Exception) {
740+
localFile.delete()
741+
throw e
742+
}
743+
if (MigrationCrypto.isEncrypted(
744+
localFile.inputStream().use { stream ->
745+
val header = ByteArray(2)
746+
stream.read(header).let { bytesRead ->
747+
if (bytesRead < 1) ByteArray(0) else header
693748
}
694-
MigrationCrypto.decrypt(rawBytes, password)
695-
} else {
696-
rawBytes
697-
}
698-
val manifestJson = extractManifestFromZip(java.io.ByteArrayInputStream(zipBytes))
699-
manifestJson?.let { json.decodeFromString<MigrationBundle>(it) to zipBytes }
749+
},
750+
)
751+
) {
752+
if (password == null) {
753+
localFile.delete()
754+
throw PasswordRequiredException("This export file is encrypted")
755+
}
756+
MigrationCrypto.decryptFile(localFile, password)
757+
}
758+
streamBundleFromZipFile(localFile) ?: run {
759+
localFile.delete()
760+
null
700761
}
701762
} catch (e: network.columba.app.migration.WrongPasswordException) {
763+
// Thrown by MigrationCrypto (GCM auth tag mismatch).
702764
Log.e(TAG, "Wrong password for encrypted export", e)
703765
throw e
704766
} catch (e: WrongPasswordException) {
767+
// data.crypto variant (identity-key decryption).
705768
Log.e(TAG, "Wrong password for encrypted export", e)
706769
throw e
707770
} catch (e: PasswordRequiredException) {
@@ -713,14 +776,21 @@ class MigrationImporter
713776
}
714777
}
715778

716-
private fun extractManifestFromZip(inputStream: java.io.InputStream): String? {
717-
ZipInputStream(inputStream).use { zipIn ->
718-
var entry = zipIn.nextEntry
719-
while (entry != null) {
720-
if (entry.name == MANIFEST_FILENAME) {
721-
return zipIn.bufferedReader().readText()
779+
/**
780+
* Parse the manifest with a streaming JSON decoder and return the
781+
* bundle together with the local ZIP file (reused for attachments).
782+
*/
783+
private fun streamBundleFromZipFile(localFile: File): Pair<MigrationBundle, File>? {
784+
localFile.inputStream().use { stream ->
785+
ZipInputStream(stream).use { zipIn ->
786+
var entry = zipIn.nextEntry
787+
while (entry != null) {
788+
if (entry.name == MANIFEST_FILENAME) {
789+
val bundle = json.decodeFromStream<MigrationBundle>(zipIn)
790+
return bundle to localFile
791+
}
792+
entry = zipIn.nextEntry
722793
}
723-
entry = zipIn.nextEntry
724794
}
725795
}
726796
return null
@@ -876,14 +946,14 @@ class MigrationImporter
876946
}
877947

878948
/**
879-
* Import attachments from the ZIP file.
949+
* Import attachments from the staged ZIP file.
880950
*/
881-
private fun importAttachments(zipBytes: ByteArray): Int {
951+
private fun importAttachments(zipFile: File): Int {
882952
val attachmentsDir = File(context.filesDir, "attachments")
883953
attachmentsDir.mkdirs()
884954

885955
return try {
886-
extractAttachmentsFromZip(java.io.ByteArrayInputStream(zipBytes), attachmentsDir)
956+
zipFile.inputStream().use { stream -> extractAttachmentsFromZip(stream, attachmentsDir) }
887957
} catch (e: Exception) {
888958
Log.e(TAG, "Failed to import attachments", e)
889959
0

0 commit comments

Comments
 (0)