如何构建透明的加密框架以保护 Android 应用程序(下)
导语:本文将对希望使数据加密过程可靠且方便的 Android 应用程序开发团队有用。
如何构建透明的加密框架以保护 Android 应用程序(上)
向我们的测试应用程序添加加密框架
我们的下一步是向测试 Android 应用程序添加新功能,该功能将对沙箱中的数据应用透明加密。该应用程序将允许用户:
查看以前保存在应用程序中的联系人
添加新联系人
在应用程序的浅色和深色主题之间进行选择
应用程序将联系人信息存储在包含字典列表的 contacts.json 文件中。每个字典都有三个字段:姓名、电话号码和电子邮件地址。以下是 contacts.json 文件中记录的外观:
有关应用程序设置的信息存储在 SharedPreferences 中。由于测试应用程序只有一个设置——颜色主题——这个文件简单地使用 0 表示浅色,1 表示深色。
以下是我们的 shared_preferences 文件的内容:
添加数据加密功能
我们将使用高级加密标准(AES) 加密应用程序沙箱中的数据,这是一种对称块加密算法。我们选择了伽罗瓦/计数器模式(GCM) 加密和 256 位密钥长度。与一个特定应用程序关联的所有文件都使用相同的密钥进行加密,并且该密钥使用在 Android Keystore 中创建的主密钥进行加密。
创建加密库
我们可以使用Tink库实现加密操作,该库提供加密 API 来加密数据。Tink 目前支持 Java、Android、C++、Objective-C、Go 和 Python,并在 Apache License 2.0 下可用。
尽管 Tink 支持 C++,但我们将使用 Java 来处理加密原语,因为它支持 Android Keystore。C++ 只能与来自 AWS 和 Google Cloud Platform 的密钥管理系统一起使用。
要开始使用 Tink,我们需要创建一个单独的 Java Android 库。描述包装函数的本机库将与此 Java 库通信并调用加密和解密方法。
让我们使用 Gradle 将 Tink 链接到我们的 Android 项目:
dependencies { ... implementation 'com.google.crypto.tink:tink-android:1.5.0' ... }
要开始使用 Tink,我们需要先对其进行初始化。初始化允许库用户选择如何实现加密原语。对于我们的任务,我们只需要支持 AES GCM 256 加密的Authenticated Encryption with Associated Data (AEAD) 原语。下面是我们如何在CryptoHelper类的加密和解密方法中注册它:
import com.google.crypto.tink.aead.AeadConfig; ... AeadConfig.register(); ...
现在,我们可以创建加密密钥、加密密钥并将其保存到应用程序沙箱中。Tink 将密钥存储在 keyset-ах 中,其中包含密钥和相关的元数据。我们可以将密钥集存储为 JSON 文件格式。最好加密此文件以确保密钥得到很好的保护。
以下是我们如何保存和加载密钥集:
import com.google.crypto.tink.JsonKeysetReader; import com.google.crypto.tink.JsonKeysetWriter; import com.google.crypto.tink.aead.AeadConfig; import com.google.crypto.tink.KeysetHandle; import com.google.crypto.tink.aead.AesGcmKeyManager; import com.google.crypto.tink.integration.android.AndroidKeystoreKmsClient; public class CryptoHelper { private static final String KEYSET_FILE_NAME = "/lde_keyset.json"; private static final String MASTER_KEY_URI = "android-keystore://LDE_MASTER_KEY"; ... private void storeKeyset(KeysetHandle keyset) throws IOException, GeneralSecurityException { String filePath = pathToFiles + KEYSET_FILE_NAME; keyset.write(JsonKeysetWriter.withFile(new File(filePath)), AndroidKeystoreKmsClient.getOrGenerateNewAeadKey(MASTER_KEY_URI)); } private KeysetHandle loadKeyset() throws IOException, GeneralSecurityException { String filePath = pathToFiles + KEYSET_FILE_NAME; File file = new File(filePath); if (file.exists()) { return KeysetHandle.read(JsonKeysetReader.withFile(file), AndroidKeystoreKmsClient.getOrGenerateNewAeadKey(MASTER_KEY_URI)); } return null; } ... }
KeysetHandle 类的 read 方法是静态的,而 write 方法不是,它需要我们通过参数将 KeysetHandle 传递给 storeKeyset 方法,该方法将 keyset 写入 keyset-ах 文件。
AndroidKeystoreKmsClient 类中的 getOrGenerateNewAeadKey 方法帮助我们使用假名从 Android Keystore 中获取加密密钥。此方法也可以创建这样的密钥。
要使用密钥集,我们需要知道 pathToFiles 应用程序的文件路径。此路径是EnableEncryption函数起作用的参数之一。密钥集是在我们调用加密方法时创建的。首先,让我们在应用程序沙箱中找到带有密钥的文件。如果它不存在,我们假设密钥尚未创建。所以我们需要生成新密钥并将其保存到应用程序文件系统中:
KeysetHandle keyset = loadKeyset(); if (keyset == null) { keyset = KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate()); storeKeyset(keyset); }
AesGcmKeyManager.aes256GcmTemplate ()函数返回确定如何为 AES GCM 算法生成 32 字节密钥的密钥模板。我们还调用了storeKeyset函数,它在沙盒的 files 目录中创建了 lde_keyset.json 文件。此文件包含加密密钥。这是我们的键集的外观:
# cat files/lde_keyset.json { "encryptedKeyset": "Cct7zebLSCF42m5llL6WijgAvC9ObLsB7TVjDkIvnWZ4d2xWUdhttcQUrT+BJVhiDoxsTKrNcHuf+gbDlqvDb\/c7TqeeLwR0sTQqyYiOhAryZhnl8tSE3vXvn2uaXBsO5U8AxUdzNHmIlaumPrvUPLKoZK\/tVHcIHqajweH9OZ2+HfJnxgs1jw==", "keysetInfo": { "primaryKeyId": 686385746, "keyInfo": [ { "typeUrl": "type.googleapis.com\/google.crypto.tink.AesGcmKey", "status": "ENABLED", "keyId": 686385746, "outputPrefixType": "TINK" } ] } }
加密和解密函数首先必须从 KeysetHandle 类初始化对象并加载现有密钥集或生成新密钥集。之后,我们必须通过调用 KeysetHandle 类的 getPrimitive 方法来获取 AEAD 原语,并使用它来加密和解密数据。
在我们的示例中,我们没有任何附加数据,因此我们可以将 null 传递给 AEAD 类的加密和解密方法。
这是我们加密机制的最后一部分:
import com.google.crypto.tink.Aead; import com.google.crypto.tink.KeysetHandle; import com.google.crypto.tink.aead.AesGcmKeyManager; ... public class CryptoHelper { ... public byte[] encrypt(byte[] plaintext) { try { KeysetHandle keyset = loadKeyset(); if (keyset == null) { keyset = KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate()); storeKeyset(keyset); } Aead aead = keyset.getPrimitive(Aead.class); byte [] result = aead.encrypt(plaintext, null); return result; } catch (IOException | GeneralSecurityException e) { Log.e(TAG, "Failed to encrypt data. Error: " + e.getMessage()); return null; } } public byte[] decrypt(byte[] encrypted) { try { KeysetHandle keyset = loadKeyset(); if (keyset == null) { return null; } Aead aead = keyset.getPrimitive(Aead.class); byte [] result = aead.decrypt(encrypted, null); return result; } catch (IOException | GeneralSecurityException e) { e.printStackTrace(); Log.e(TAG, "Failed to decrypt data. Error: " + e.getMessage()); return null; } } }
构建框架
现在,我们可以结合前两个阶段,在包装函数中配置加密和解密的调用。因此,让我们开始为 android 应用程序添加一个加密框架。要同时使用 ReadHook 和 WriteHook 方法,我们需要 CryptoHelper Java 类。我们可以使用Java Native Interface (JNI) 调用这个 Java 类。
执行以下步骤需要此机制:
在 Java 虚拟机初始化时提供的目录列表中找到 Java 类
获取构造方法的标识符
创建一个新的 Java 对象
获取目标方法的标识符
调用对象的目标方法
这些步骤中的每一个都需要我们使用 JNIEnv——一个指向存储所有 JNI 函数指针的结构的指针。JNIEnv 指针是我们可以在从 Java 代码调用的本机方法的签名中找到的两个附加参数之一。然而,这个指针是一个局部引用,这意味着我们只能在它被发送到的线程中使用它;当它退出本机方法时,它会停止工作。
我们的 ReadHook 和 WriteHook 方法不是直接从 Java 代码调用的,它们仍然需要 JNIEnv。但是我们可以存储指向 JavaVM 的指针,我们可以在需要时随时从中获取 JNIEnv。我们可以使用GetJavaVM函数获取指向 JavaVM 的指针:
JavaVM* g_vm; env->GetJavaVM(&g_vm);
然后,我们可以使用GetEnv函数从 JavaVM 获取指向 JNIEnv 的指针:
JNIEnv* JvmWrapper::GetEnv() { JNIEnv *env; int status = m_jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6); if (status != JNI_OK) { return nullptr; } return env; }
此代码可能在未附加到 Java VM 的线程中执行。在这种情况下,GetEnv函数会将 JNIEnv 值设置为 null 并返回 JNI_EDETACHED 状态。理论上,我们可以使用AttachCurrentThread函数来处理这种情况,该函数将所需的线程添加到 Java VM 并返回 JNIEnv 指针。但是由于我们的应用程序不支持多线程,所以没有必要这样做。
ReadHook 和 WriteHook 方法都需要对 CryptoHelper 类的引用。默认情况下,这是一个本地引用,但我们可以使用NewGlobalRef函数将其转换为全局引用:
auto randomClass = env->FindClass("com/example/cryptohelper/CryptoHelper"); m_cryptoHelperHandler = reinterpret_cast<jclass>(env->NewGlobalRef(randomClass));
当应用程序停止工作时,我们需要使用DeleteGlobalRef函数删除这个引用。否则,引用可能会导致资源泄漏。
包装函数的算法
让我们检查一下我们的包装函数是如何工作的。要读取数据,他们:
获取必要文件的加密内容
解密内容
如果可能,返回请求的解密数据
写入时,我们的函数通过两个简单的步骤重写文件的全部内容:
加密我们要添加到文件中的数据
用新的加密数据重写文件
然而,这个算法只有在我们可以在一次迭代中写入所有必要的数据时才有效。如果我们需要写入大量数据,我们必须添加分段读写的选项。除了 Tink 所需的密钥集文件外,该框架也不应该加密应用程序沙箱中的文件以外的任何内容。包装函数必须能够检查它们使用的文件的位置及其名称,这应该与 lde_keyset 不同。
如果我们知道描述符并使用readLink函数,我们可以发现文件的名称:
std::string GetPathByFd(int fd) { char filePath[PATH_MAX]; std::string processDescriptors = "/proc/self/fd/" + std::to_string(fd); if (readlink(processDescriptors.c_str(), filePath, PATH_MAX) == -1) { LOGE("Failed to get file path via fd. Error: %s", strerror(errno)); return std::string(); } return filePath; }
考虑到这些细节,让我们来看看我们的包装函数的算法。读取函数具有以下初始数据:
fd — 包含加密数据的文件的描述符
buf — 指向应该包含读取数据的缓冲区的指针
count — 包装函数必须读取的数据大小
以下是包装函数的工作原理:
数据加密的包装函数算法
写入函数具有以下初始数据:
fd — 函数写入数据的文件的描述符
buf — 指向包含写入数据的缓冲区的指针
count — 写入数据的大小
这个函数的算法有点类似于read函数:
数据解密的包装函数算法
框架限制和进一步改进
我们为 Android 沙盒创建了一个透明数据加密框架。我们的包装器函数旨在使用 shared_preferences 和 JSON 文件在测试应用程序中工作。
在当前状态下,我们的解决方案仅涵盖读写功能的基本使用。它不能用于多线程应用程序或使用 O_APPEND、O_ASYNC、O_CLOEXEC、O_DIRECT、O_DIRECTORY、O_DSYNC、O_EXCL、O_NOATIME、O_NOCTTY、O_NOFOLLOW、O_NONBLOCK、O_PATH 或 O_SYNC 标志打开文件。
我们的框架也可能会遇到性能限制,因为我们使用了块加密算法。与流加密算法相比,AES 需要更多的计算能力来加密大文件。我们在示例框架中使用了 AES,因为通常建议加密本地数据。
我们可以通过以下方式进一步改进我们的框架:
将加密过程从 Java 转移到本机代码。这将使我们摆脱对 JNI 的资源需求调用。为此,我们还必须将 Tink 替换为另一个库,例如LibTomCrypt——一个不需要大量资源的公共领域的简单库。
实现流加密。一种新的加密算法将提高框架的性能,并且能够在不解密所有文件内容的情况下写入数据。
结论
我们创建的示例框架允许加密和解密应用程序沙箱中的文件,而不会减慢应用程序本身的速度。有了它,我们甚至可以在有根的 Android 设备上保护我们应用程序的敏感数据。
我们的移动应用程序开发和网络安全专家正在构建一个加密框架,以保护 Android 应用程序的安全,使其对实际项目更加有用。
发表评论