|
| 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 | +} |
0 commit comments