Skip to content

Commit c40cae2

Browse files
committed
feat(e2ee): add biometric vault media preview support
Signed-off-by: m.jebarat <m.jebarat@skillandyou.com>
1 parent 80682ea commit c40cae2

62 files changed

Lines changed: 4128 additions & 51 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ dependencies {
401401

402402
// region UI
403403
implementation(libs.bundles.ui)
404+
implementation(libs.biometric)
404405
implementation(libs.browser)
405406
// endregion
406407

app/src/main/java/com/nextcloud/client/di/AppComponent.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.nextcloud.client.appinfo.AppInfoModule;
1414
import com.nextcloud.client.database.DatabaseModule;
1515
import com.nextcloud.client.device.DeviceModule;
16+
import com.nextcloud.client.e2ee.vault.E2eeVaultSession;
1617
import com.nextcloud.client.integrations.IntegrationsModule;
1718
import com.nextcloud.client.jobs.JobsModule;
1819
import com.nextcloud.client.jobs.download.FileDownloadHelper;
@@ -78,6 +79,8 @@ public interface AppComponent {
7879

7980
void inject(FolderDownloadWorkerReceiver folderDownloadWorkerReceiver);
8081

82+
E2eeVaultSession e2eeVaultSession();
83+
8184
@Component.Builder
8285
interface Builder {
8386
@BindsInstance

app/src/main/java/com/nextcloud/client/di/AppModule.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
import com.nextcloud.client.core.ThreadPoolAsyncRunner;
2929
import com.nextcloud.client.database.dao.ArbitraryDataDao;
3030
import com.nextcloud.client.device.DeviceInfo;
31+
import com.nextcloud.client.e2ee.vault.AndroidKeystoreE2eeVaultSecretCipher;
32+
import com.nextcloud.client.e2ee.vault.E2eeVaultSecretCipher;
33+
import com.nextcloud.client.e2ee.vault.E2eeVaultSessionConfig;
3134
import com.nextcloud.client.jobs.operation.FileOperationHelper;
3235
import com.nextcloud.client.logger.FileLogHandler;
3336
import com.nextcloud.client.logger.Logger;
@@ -252,6 +255,12 @@ PassCodeManager passCodeManager(AppPreferences preferences, Clock clock) {
252255
return new PassCodeManager(preferences, clock);
253256
}
254257

258+
@Provides
259+
@Singleton
260+
E2eeVaultSecretCipher e2eeVaultSecretCipher(E2eeVaultSessionConfig config) {
261+
return new AndroidKeystoreE2eeVaultSecretCipher(config);
262+
}
263+
255264
@Provides
256265
FileOperationHelper fileOperationHelper(CurrentAccountProvider currentAccountProvider, Context context) {
257266
return new FileOperationHelper(currentAccountProvider.getUser(), context, fileDataStorageManager(currentAccountProvider, context));
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.client.e2ee.vault
8+
9+
import android.os.Build
10+
import android.security.keystore.KeyGenParameterSpec
11+
import android.security.keystore.KeyProperties
12+
import java.nio.charset.StandardCharsets
13+
import java.security.KeyStore
14+
import java.security.MessageDigest
15+
import java.util.Base64
16+
import javax.crypto.Cipher
17+
import javax.crypto.KeyGenerator
18+
import javax.crypto.SecretKey
19+
import javax.crypto.spec.GCMParameterSpec
20+
import javax.inject.Inject
21+
import kotlin.math.max
22+
23+
class AndroidKeystoreE2eeVaultSecretCipher @Inject constructor(private val config: E2eeVaultSessionConfig) :
24+
E2eeVaultSecretCipher {
25+
override fun encrypt(accountName: String, plaintext: ByteArray): E2eeVaultEncryptedPayload {
26+
val cipher = Cipher.getInstance(TRANSFORMATION)
27+
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey(accountName))
28+
29+
return E2eeVaultEncryptedPayload(
30+
initializationVector = encoder.encodeToString(cipher.iv),
31+
ciphertext = encoder.encodeToString(cipher.doFinal(plaintext))
32+
)
33+
}
34+
35+
override fun decrypt(accountName: String, payload: E2eeVaultEncryptedPayload): ByteArray {
36+
val cipher = Cipher.getInstance(TRANSFORMATION)
37+
val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, decoder.decode(payload.initializationVector))
38+
cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(accountName), spec)
39+
40+
return cipher.doFinal(decoder.decode(payload.ciphertext))
41+
}
42+
43+
override fun deleteKey(accountName: String) {
44+
val keyStore = loadKeyStore()
45+
val alias = keyAlias(accountName)
46+
47+
if (keyStore.containsAlias(alias)) {
48+
keyStore.deleteEntry(alias)
49+
}
50+
}
51+
52+
private fun getOrCreateSecretKey(accountName: String): SecretKey {
53+
val keyStore = loadKeyStore()
54+
val alias = keyAlias(accountName)
55+
val existingKey = keyStore.getKey(alias, null) as? SecretKey
56+
57+
if (existingKey != null) {
58+
return existingKey
59+
}
60+
61+
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
62+
keyGenerator.init(createKeySpec(alias))
63+
64+
return keyGenerator.generateKey()
65+
}
66+
67+
private fun loadKeyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
68+
load(null)
69+
}
70+
71+
@Suppress("DEPRECATION")
72+
private fun createKeySpec(alias: String): KeyGenParameterSpec {
73+
val builder = KeyGenParameterSpec.Builder(
74+
alias,
75+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
76+
)
77+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
78+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
79+
.setUserAuthenticationRequired(true)
80+
81+
val validitySeconds = max(
82+
MINIMUM_AUTHENTICATION_VALIDITY_SECONDS,
83+
config.unlockDurationMillis / MILLIS_PER_SECOND
84+
)
85+
.toInt()
86+
87+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
88+
builder.setUserAuthenticationParameters(
89+
validitySeconds,
90+
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
91+
)
92+
} else {
93+
builder.setUserAuthenticationValidityDurationSeconds(validitySeconds)
94+
}
95+
96+
return builder.build()
97+
}
98+
99+
private fun keyAlias(accountName: String): String {
100+
val digest = MessageDigest.getInstance("SHA-256")
101+
.digest(accountName.toByteArray(StandardCharsets.UTF_8))
102+
val accountHash = encoder.encodeToString(digest)
103+
104+
return "$KEY_ALIAS_PREFIX$accountHash"
105+
}
106+
107+
companion object {
108+
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
109+
private const val AUTHENTICATION_TAG_LENGTH_BITS = 128
110+
private const val KEY_ALIAS_PREFIX = "nextcloud.e2ee.vault."
111+
private const val MILLIS_PER_SECOND = 1_000L
112+
private const val MINIMUM_AUTHENTICATION_VALIDITY_SECONDS = 1L
113+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
114+
115+
private val encoder: Base64.Encoder = Base64.getUrlEncoder().withoutPadding()
116+
private val decoder: Base64.Decoder = Base64.getUrlDecoder()
117+
}
118+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.client.e2ee.vault
8+
9+
import android.media.MediaDataSource
10+
import kotlin.math.min
11+
12+
class E2eeByteArrayMediaDataSource(private val bytes: ByteArray) : MediaDataSource() {
13+
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
14+
if (position < 0 || position > Int.MAX_VALUE || position >= bytes.size) {
15+
return END_OF_STREAM
16+
}
17+
18+
if (offset < 0 || offset > buffer.size || size < 0) {
19+
return END_OF_STREAM
20+
}
21+
22+
val requestedLength = min(size, buffer.size - offset)
23+
if (requestedLength == 0) {
24+
return 0
25+
}
26+
27+
val start = position.toInt()
28+
val length = min(requestedLength, bytes.size - start)
29+
bytes.copyInto(buffer, offset, start, start + length)
30+
return length
31+
}
32+
33+
override fun getSize(): Long = bytes.size.toLong()
34+
35+
override fun close() = Unit
36+
37+
companion object {
38+
private const val END_OF_STREAM = -1
39+
}
40+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.client.e2ee.vault
8+
9+
import android.graphics.Bitmap
10+
11+
interface E2eeImageDecoder {
12+
fun decode(bytes: ByteArray, requestedWidth: Int, requestedHeight: Int): Bitmap?
13+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.client.e2ee.vault
8+
9+
import android.graphics.Bitmap
10+
import android.graphics.BitmapFactory
11+
import androidx.exifinterface.media.ExifInterface
12+
import com.nextcloud.utils.rotateBitmapViaExif
13+
import java.io.ByteArrayInputStream
14+
15+
class E2eeImagePreviewDecoder : E2eeImageDecoder {
16+
override fun decode(bytes: ByteArray, requestedWidth: Int, requestedHeight: Int): Bitmap? {
17+
val bounds = BitmapFactory.Options().apply {
18+
inJustDecodeBounds = true
19+
}
20+
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
21+
22+
return if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
23+
null
24+
} else {
25+
decodeSampledBitmap(bytes, bounds, requestedWidth, requestedHeight)
26+
}
27+
}
28+
29+
private fun decodeSampledBitmap(
30+
bytes: ByteArray,
31+
bounds: BitmapFactory.Options,
32+
requestedWidth: Int,
33+
requestedHeight: Int
34+
): Bitmap? {
35+
val options = BitmapFactory.Options().apply {
36+
inSampleSize = calculateSampleSize(bounds, requestedWidth, requestedHeight)
37+
}
38+
39+
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)?.let { bitmap ->
40+
bitmap.rotateBitmapViaExif(readOrientation(bytes))
41+
}
42+
}
43+
44+
private fun calculateSampleSize(options: BitmapFactory.Options, requestedWidth: Int, requestedHeight: Int): Int {
45+
var sampleSize = 1
46+
47+
while (options.outWidth / sampleSize > requestedWidth * SAMPLE_FACTOR ||
48+
options.outHeight / sampleSize > requestedHeight * SAMPLE_FACTOR
49+
) {
50+
sampleSize *= SAMPLE_FACTOR
51+
}
52+
53+
return sampleSize
54+
}
55+
56+
private fun readOrientation(bytes: ByteArray): Int = runCatching {
57+
ExifInterface(ByteArrayInputStream(bytes)).getAttributeInt(
58+
ExifInterface.TAG_ORIENTATION,
59+
ExifInterface.ORIENTATION_NORMAL
60+
)
61+
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
62+
63+
companion object {
64+
private const val SAMPLE_FACTOR = 2
65+
}
66+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.client.e2ee.vault
8+
9+
import android.content.Context
10+
import android.graphics.Bitmap
11+
import com.nextcloud.client.account.User
12+
import com.owncloud.android.datamodel.ArbitraryDataProvider
13+
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
14+
import com.owncloud.android.datamodel.FileDataStorageManager
15+
import com.owncloud.android.datamodel.OCFile
16+
import com.owncloud.android.lib.common.OwnCloudClient
17+
import com.owncloud.android.utils.MimeTypeUtil
18+
19+
class E2eeImagePreviewProvider internal constructor(
20+
private val user: User,
21+
private val storageManager: FileDataStorageManager,
22+
private val session: E2eeVaultSession,
23+
private val mediaFileLoader: E2eePlaintextMediaLoader,
24+
private val decoder: E2eeImageDecoder
25+
) {
26+
@JvmOverloads
27+
constructor(
28+
context: Context,
29+
user: User,
30+
storageManager: FileDataStorageManager,
31+
session: E2eeVaultSession,
32+
arbitraryDataProvider: ArbitraryDataProvider = ArbitraryDataProviderImpl(context)
33+
) : this(
34+
user,
35+
storageManager,
36+
session,
37+
E2eeMediaFileLoader(context, user, arbitraryDataProvider),
38+
E2eeImagePreviewDecoder()
39+
)
40+
41+
fun loadBitmap(file: OCFile, client: OwnCloudClient, width: Int, height: Int): Bitmap? {
42+
val parent = if (canLoadPreview(file)) {
43+
storageManager.getFileByEncryptedRemotePath(file.parentRemotePath)
44+
} else {
45+
null
46+
}
47+
48+
return if (parent != null && session.isUnlocked(E2eeVaultSessionKey(user.accountName, parent.localId))) {
49+
mediaFileLoader.withPlaintext(file, parent, client) { plaintext ->
50+
decoder.decode(plaintext, width, height)
51+
}
52+
} else {
53+
null
54+
}
55+
}
56+
57+
private fun canLoadPreview(file: OCFile): Boolean = file.isEncrypted && !file.isFolder && MimeTypeUtil.isImage(file)
58+
}

0 commit comments

Comments
 (0)