Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, ItemKind>()
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
}

Expand Down Expand Up @@ -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<Exception>(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<String>?): Array<String> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() },
Expand All @@ -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)
Expand All @@ -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"
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File, File> {
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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 19 in android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4aX_8lTtgRWlm1zWch&open=AZ4aX_8lTtgRWlm1zWch&pullRequest=451
client: OkHttpClient,
shards: List<Shard>,
target: File,
signal: CancellationSignal?
) {
require(shards.isNotEmpty()) { "No shards to download" }
target.parentFile?.mkdirs()
if (target.exists()) target.delete()

Check warning on line 27 in android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do something with the "Boolean" value returned by "delete".

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4aX_8lTtgRWlm1zWcg&open=AZ4aX_8lTtgRWlm1zWcg&pullRequest=451

val activeCall = AtomicReference<Call?>(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()
}
}
}
1 change: 1 addition & 0 deletions src/services/native/InternxtAuthCredentialsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface InternxtAuthCredentials {
bearerToken: string;
userId: string;
bridgeUser: string;
mnemonic: string;
rootFolderUuid: string;
email?: string | null;
driveBaseUrl: string;
Expand Down
1 change: 1 addition & 0 deletions src/store/slices/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading