diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt index a183dee4b..8f0e3fc22 100644 --- a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt @@ -23,6 +23,7 @@ class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : bearerToken = map.requireString("bearerToken"), userId = map.requireString("userId"), bridgeUser = map.requireString("bridgeUser"), + mnemonic = map.requireString("mnemonic"), rootFolderUuid = map.requireString("rootFolderUuid"), email = map.optString("email"), driveBaseUrl = map.requireString("driveBaseUrl"), diff --git a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt index 835b34631..a1714d3eb 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -3,6 +3,9 @@ package com.internxt.cloud.documents import android.database.Cursor import android.database.MatrixCursor import android.os.CancellationSignal +import android.os.Handler +import android.os.HandlerThread +import android.os.OperationCanceledException import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsContract.Document @@ -14,18 +17,36 @@ import com.internxt.cloud.documents.api.InternxtApiClient import com.internxt.cloud.documents.api.InternxtApiException import com.internxt.cloud.documents.api.model.TrashItem import com.internxt.cloud.documents.auth.InternxtAuthManager +import com.internxt.cloud.documents.cache.DocumentCache +import com.internxt.cloud.documents.crypto.FileKeyDeriver +import com.internxt.cloud.documents.download.EncryptedFileDownloader +import com.rncrypto.util.CryptoService +import okhttp3.OkHttpClient +import java.io.File import java.io.FileNotFoundException import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager private val itemKinds = ConcurrentHashMap() + private lateinit var closeHandler: Handler + private val downloadClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.MINUTES) + .writeTimeout(2, TimeUnit.MINUTES) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + } private enum class ItemKind { FILE, FOLDER } override fun onCreate(): Boolean { authManager = InternxtAuthManager.create(context!!.applicationContext) + closeHandler = Handler(HandlerThread("InternxtDocsClose").apply { start() }.looper) return true } @@ -218,7 +239,79 @@ class InternxtDocumentsProvider : DocumentsProvider() { mode: String?, signal: CancellationSignal? ): ParcelFileDescriptor { - throw UnsupportedOperationException("Not implemented yet") + val ctx = context ?: throw FileNotFoundException("No context") + val id = documentId ?: throw FileNotFoundException("No document id") + if (mode != null && mode != "r") { + throw UnsupportedOperationException("Only read-only access is supported (mode=$mode)") + } + + val cfg = authManager.loadAuthConfig() ?: throw FileNotFoundException("Not authenticated") + val api = InternxtApiClient(cfg) + + val file = try { + api.getFile(id) ?: throw FileNotFoundException("File not found: $id") + } catch (e: InternxtApiException) { + throw FileNotFoundException("getFile $id failed: ${e.message}") + } + val bucket = file.bucket ?: throw FileNotFoundException("File $id has no bucket") + val fileId = file.fileId ?: throw FileNotFoundException("File $id has no fileId") + val updatedAt = file.updatedAt ?: throw FileNotFoundException("File $id has no updatedAt") + + val cacheFile = DocumentCache.cacheFileFor(ctx, id, updatedAt) + if (cacheFile.exists() && cacheFile.length() > 0) { + Log.d(TAG, "openDocument cache hit id=$id") + return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) + } + + val (tempEnc, tempDec) = DocumentCache.tempPaths(ctx, id) + try { + signal?.throwIfCanceled() + val links = api.getDownloadLinks(bucket, fileId) + EncryptedFileDownloader.download(downloadClient, links.shards, tempEnc, signal) + + signal?.throwIfCanceled() + val key = FileKeyDeriver.deriveFileKey(cfg.mnemonic, bucket, links.index) + val iv = FileKeyDeriver.deriveIv(links.index) + decryptBlocking(tempEnc, tempDec, FileKeyDeriver.toHex(key), FileKeyDeriver.toHex(iv)) + + if (!tempDec.renameTo(cacheFile)) { + throw FileNotFoundException("Failed to promote temp file to cache for $id") + } + tempEnc.delete() + DocumentCache.pruneSiblings(ctx, id, cacheFile) + + Log.d(TAG, "openDocument cached id=$id size=${cacheFile.length()}") + return ParcelFileDescriptor.open( + cacheFile, + ParcelFileDescriptor.MODE_READ_ONLY, + closeHandler, + ) { DocumentCache.deleteTempsFor(ctx, id) } + } catch (e: OperationCanceledException) { + tempEnc.delete(); tempDec.delete() + throw FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } + } catch (e: Exception) { + tempEnc.delete(); tempDec.delete() + Log.w(TAG, "openDocument $id failed: ${e.javaClass.simpleName}: ${e.message}") + if (e is FileNotFoundException) throw e + throw FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } + } + } + + private fun decryptBlocking(src: File, dst: File, hexKey: String, hexIv: String) { + val latch = CountDownLatch(1) + val errorRef = arrayOfNulls(1) + CryptoService.getInstance().decryptFile( + src.absolutePath, + dst.absolutePath, + hexKey, + hexIv, + /* runInBackground = */ true, + ) { err -> + errorRef[0] = err + latch.countDown() + } + latch.await() + errorRef[0]?.let { throw it } } private fun resolveRootProjection(projection: Array?): Array = diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt index 239ccdb50..52755fce9 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt @@ -6,6 +6,7 @@ data class AuthConfig( val bearerToken: String, val bridgeUser: String, val userId: String, + val mnemonic: String, val clientName: String, val clientVersion: String, val desktopToken: String? = null diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt index 9d4856c35..de69cba88 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -13,6 +13,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { val bearerToken: String, val userId: String, val bridgeUser: String, + val mnemonic: String, val rootFolderUuid: String, val email: String?, val driveBaseUrl: String, @@ -37,6 +38,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { bearerToken = required(KEY_BEARER_TOKEN), bridgeUser = required(KEY_BRIDGE_USER), userId = required(KEY_USER_ID), + mnemonic = required(KEY_MNEMONIC), clientName = BuildConfig.INTERNXT_CLIENT_NAME, clientVersion = BuildConfig.INTERNXT_CLIENT_VERSION, desktopToken = prefs.getString(KEY_DESKTOP_TOKEN, null)?.takeIf { it.isNotBlank() }, @@ -51,6 +53,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { .putString(KEY_BEARER_TOKEN, creds.bearerToken) .putString(KEY_USER_ID, creds.userId) .putString(KEY_BRIDGE_USER, creds.bridgeUser) + .putString(KEY_MNEMONIC, creds.mnemonic) .putString(KEY_ROOT_FOLDER_UUID, creds.rootFolderUuid) .putString(KEY_EMAIL, creds.email) .putString(KEY_DRIVE_BASE_URL, creds.driveBaseUrl) @@ -69,6 +72,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { private const val KEY_BEARER_TOKEN = "bearerToken" private const val KEY_USER_ID = "userId" private const val KEY_BRIDGE_USER = "bridgeUser" + private const val KEY_MNEMONIC = "mnemonic" private const val KEY_ROOT_FOLDER_UUID = "rootFolderUuid" private const val KEY_EMAIL = "email" private const val KEY_DRIVE_BASE_URL = "driveBaseUrl" @@ -79,6 +83,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { KEY_BEARER_TOKEN, KEY_USER_ID, KEY_BRIDGE_USER, + KEY_MNEMONIC, KEY_ROOT_FOLDER_UUID, KEY_DRIVE_BASE_URL, KEY_BRIDGE_BASE_URL, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt new file mode 100644 index 000000000..fcf8c2f78 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt @@ -0,0 +1,51 @@ +package com.internxt.cloud.documents.cache + +import android.content.Context +import java.io.File + +object DocumentCache { + + private const val ROOT_DIR = "internxt_documents" + private const val CACHE_DIR = "cache" + private const val TMP_DIR = "tmp" + private const val DEC_SUFFIX = ".dec" + private const val ENC_SUFFIX = ".enc" + + fun cacheFileFor(context: Context, uuid: String, updatedAt: String): File { + val dir = File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR").apply { mkdirs() } + return File(dir, "${uuid}_${slugFromUpdatedAt(updatedAt)}$DEC_SUFFIX") + } + + fun tempPaths(context: Context, uuid: String): Pair { + val dir = File(context.cacheDir, "$ROOT_DIR/$TMP_DIR").apply { mkdirs() } + val token = "${uuid}_${System.nanoTime()}" + return File(dir, "$token$ENC_SUFFIX") to File(dir, "$token$DEC_SUFFIX") + } + + fun pruneSiblings(context: Context, uuid: String, keep: File) { + val dir = File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR") + val children = dir.listFiles() ?: return + for (file in children) { + if (file == keep) continue + if (file.name.startsWith("${uuid}_") && file.name.endsWith(DEC_SUFFIX)) { + file.delete() + } + } + } + + fun deleteTempsFor(context: Context, uuid: String) { + val dir = File(context.cacheDir, "$ROOT_DIR/$TMP_DIR") + val children = dir.listFiles() ?: return + for (file in children) { + if (file.name.startsWith("${uuid}_")) file.delete() + } + } + + fun slugFromUpdatedAt(updatedAt: String): String { + val sb = StringBuilder(updatedAt.length) + for (c in updatedAt) { + if (c.isLetterOrDigit()) sb.append(c) + } + return if (sb.isEmpty()) "0" else sb.toString() + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt new file mode 100644 index 000000000..6b1439634 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt @@ -0,0 +1,58 @@ +package com.internxt.cloud.documents.crypto + +import com.rncrypto.util.CryptoService +import java.security.MessageDigest + +object FileKeyDeriver { + + private const val PBKDF2_SALT = "mnemonic" + private const val PBKDF2_ROUNDS = 2048 + private const val SEED_LENGTH = 64 + private const val FILE_KEY_LENGTH = 32 + private const val IV_LENGTH = 16 + + fun deriveFileKey(mnemonic: String, bucketIdHex: String, indexHex: String): ByteArray { + val seed = pbkdf2Seed(mnemonic) + val bucketKey = sha512(seed, hexDecode(bucketIdHex)) + val fileKey = sha512(bucketKey.copyOf(FILE_KEY_LENGTH), hexDecode(indexHex)) + return fileKey.copyOf(FILE_KEY_LENGTH) + } + + fun deriveIv(indexHex: String): ByteArray = hexDecode(indexHex).copyOf(IV_LENGTH) + + fun toHex(bytes: ByteArray): String { + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) { + val v = b.toInt() and 0xff + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0f]) + } + return sb.toString() + } + + private fun pbkdf2Seed(mnemonic: String): ByteArray = + CryptoService.getInstance().pbkdf2(mnemonic, PBKDF2_SALT.toByteArray(Charsets.UTF_8), PBKDF2_ROUNDS, SEED_LENGTH) + + private fun sha512(key: ByteArray, data: ByteArray): ByteArray { + val md = MessageDigest.getInstance("SHA-512") + md.update(key) + md.update(data) + return md.digest() + } + + private fun hexDecode(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Hex string must have even length" } + val out = ByteArray(hex.length / 2) + var i = 0 + while (i < hex.length) { + val hi = Character.digit(hex[i], 16) + val lo = Character.digit(hex[i + 1], 16) + require(hi >= 0 && lo >= 0) { "Invalid hex character at position $i" } + out[i / 2] = ((hi shl 4) or lo).toByte() + i += 2 + } + return out + } + + private val HEX = "0123456789abcdef".toCharArray() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt new file mode 100644 index 000000000..c57e27b98 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt @@ -0,0 +1,67 @@ +package com.internxt.cloud.documents.download + +import android.os.CancellationSignal +import android.os.OperationCanceledException +import com.internxt.cloud.documents.api.model.Shard +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +object EncryptedFileDownloader { + + private const val COPY_BUFFER_SIZE = 16 * 1024 + + @Throws(IOException::class, OperationCanceledException::class) + fun download( + client: OkHttpClient, + shards: List, + target: File, + signal: CancellationSignal? + ) { + require(shards.isNotEmpty()) { "No shards to download" } + target.parentFile?.mkdirs() + if (target.exists()) target.delete() + + val activeCall = AtomicReference(null) + signal?.setOnCancelListener { activeCall.get()?.cancel() } + + FileOutputStream(target, /* append = */ true).use { out -> + for (shard in shards.sortedBy { it.index }) { + signal?.throwIfCanceled() + + val request = Request.Builder().url(shard.url).get().build() + val call = client.newCall(request) + activeCall.set(call) + + try { + call.execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Shard ${shard.index} HTTP ${response.code}") + } + val body = response.body ?: throw IOException("Shard ${shard.index} empty body") + val buffer = ByteArray(COPY_BUFFER_SIZE) + body.byteStream().use { input -> + while (true) { + val read = input.read(buffer) + if (read == -1) break + out.write(buffer, 0, read) + } + } + } + } catch (e: IOException) { + if (signal?.isCanceled == true) { + throw OperationCanceledException("Download cancelled").apply { initCause(e) } + } + throw e + } finally { + activeCall.set(null) + } + } + out.flush() + } + } +} diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts index ae296f24d..e3b64a9f6 100644 --- a/src/services/native/InternxtAuthCredentialsModule.ts +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -4,6 +4,7 @@ export interface InternxtAuthCredentials { bearerToken: string; userId: string; bridgeUser: string; + mnemonic: string; rootFolderUuid: string; email?: string | null; driveBaseUrl: string; diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 52f88979d..b59013e88 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -41,6 +41,7 @@ async function syncNativeCredentials(token: string, user: UserSettings): Promise bearerToken: token, userId: user.userId, bridgeUser: user.bridgeUser, + mnemonic: user.mnemonic, rootFolderUuid: user.rootFolderUuid || user.rootFolderId, email: user.email, driveBaseUrl: appService.constants.DRIVE_NEW_API_URL,