• 日常搜索
  • 百度一下
  • Google
  • 在线工具
  • 搜转载

如何在Android上安全地存储数据

如今,应用程序的可信度在很大程度上取决于如何管理用户的私人数据。android 堆栈有许多强大的api围绕凭证和密钥存储,特定功能仅在某些版本中可用。 

这个简短的系列将从一个简单的方法开始,通过查看存储系统以及如何通过用户提供的密码加密和存储敏感数据来启动和运行。在第二个教程中,我们将研究更复杂的保护密钥和凭证的方法。

基础知识

要考虑的第一个问题是您实际需要获取多少数据。一个好的方法是避免存储私人数据,如果你真的不需要的话。

对于您必须存储的数据,Android 架构随时可以提供帮助。自 6.0 Marshmallow 起,默认情况下会为具有该功能的设备启用全盘加密。SharedPreferences由应用程序保存的文件会自动使用MODE_PRIVATE const ant 进行设置。这意味着数据只能由您自己的应用程序访问。 

坚持这个默认值是个好主意。您可以在保存共享首选项时明确设置它。

SharedPreferences.Editor editor = getSharedPreferences("preferenceName", MODE_PRIVATE).edit();
editor.putString("key", "value");
editor.commit();

或者在保存文件时。

FileOutputStream fos = openFileOutput(filenameString, Context.MODE_PRIVATE);
fos.write(data);
fos.close();

避免将数据存储在外部存储上,因为其他应用程序和用户可以看到这些数据。事实上,为了让人们更难复制您的应用程序二进制文件和数据,您可以阻止用户将应用程序安装在外部存储上。将android:installLocation值添加internalOnly到清单文件将完成此操作。

您还可以阻止备份应用程序及其数据。这也可以防止使用adb backup. 为此,请 在清单文件中将android:allowBackup属性设置为。false默认情况下,此属性设置为true。

这些是最佳实践,但它们不适用于妥协或有根设备,并且磁盘加密仅在使用锁定屏幕保护设备时才有用。这就是拥有通过加密保护其数据的应用程序端密码的好处。

使用密码保护用户数据

Conceal是加密库的绝佳选择,因为它可以让您快速启动并运行,而无需担心底层细节。但是,针对流行框架的漏洞利用目标将同时影响依赖它的所有应用程序。 

了解加密系统的工作原理也很重要,以便能够判断您是否安全地使用特定框架。因此,在这篇文章中,我们将直接查看密码学提供者来亲自动手。 

AES 和基于密码的密钥派生

我们将使用推荐的AES标准,该标准对给定密钥的数据进行加密。用于加密数据的相同密钥用于解密数据,这称为对称加密。有不同的密钥大小,AES256(256 位)是用于敏感数据的首选长度。

虽然您的应用程序的用户体验应该强制用户使用强密码,但另一个用户也有可能选择相同的密码。将我们加密数据的安全性交到用户手中是不安全的。我们需要使用随机且足够大(即具有足够熵)的密钥来保护我们的数据以被认为是强大的。这就是为什么不建议直接使用密码来加密数据的原因——这就是基于密码的密钥派生函数(PBKDF2) 发挥作用的地方。 

PBKDF2通过使用盐多次散列密码从密码中派生密钥。这称为键拉伸。盐只是一个随机的数据序列,即使其他人使用相同的密码,它也会使派生的密钥唯一。 

让我们从生成盐开始。 

SecureRandom random = new SecureRandom();
byte salt[] = new byte[256];
random.nextBytes(salt);

该类SecureRandom保证生成的输出将难以预测——它是一个“加密强的随机数生成器”。我们现在可以将盐和密码放入基于密码的加密对象中:  PBEKeySpec. 对象的构造函数也采用迭代计数形式,使键更强大。这是因为增加迭代次数会延长在蛮力攻击期间对一组键进行操作所需的时间。然后PBEKeySpec将传递给SecretKeyFactory,最终将键生成为byte[]数组。我们将把原始byte[]数组包装成一个SecretKeySpec对象。

char[] passwordChar = passwordString.toCharArray(); //Turn password into char[] array
PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256); //1324 iterations
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded();
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");

请注意,密码作为char[]数组传递,并且PBEKeySpec该类也将其存储为char[]数组。char[]数组通常用于加密函数,因为虽然String类是不可变的,但char[]包含敏感信息的数组可以被覆盖——从而从设备的内存中完全删除敏感数据。

初始化向量

我们现在已准备好加密数据,但我们还有一件事要做。AES 有不同的加密模式,但我们将使用推荐的一种:密码块链接 (CBC)。这一次对我们的数据进行一个块操作。这种模式的好处在于,每个下一个未加密的数据块都与前一个加密块进行异或运算,以使加密更强。然而,这意味着第一个区块永远不会像所有其他区块一样独特! 

如果要加密的消息与另一条要加密的消息开始时相同,则开始加密的输出将是相同的,这将为攻击者提供线索以找出消息可能是什么。解决方案是使用初始化向量 (IV)。 

IV 只是一个随机字节块,它将与第一个用户数据块进行异或。由于每个块都依赖于在此之前处理的所有块,因此整个消息将被唯一加密——使用相同密钥加密的相同消息不会产生相同的结果。 

现在让我们创建一个 IV。

SecureRandom ivRandom = new SecureRandom(); //not caching previous seeded instance of SecureRandom
byte[] iv = new byte[16];
ivRandom.nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);

关于SecureRandom. 在 4.3 及以下版本中,由于底层伪随机数生成器 ( PRNG )的初始化不当,java 密码体系结构存在漏洞。如果您的目标是 4.3 及以下版本,则可以使用修复程序。

加密数据

有了IvParameterSpec,我们现在可以进行实际的加密了。

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
byte[] encrypted = cipher.doFinal(plainTextBytes);

这里我们传入字符串 。这指定了带有密码块链接的 AES 加密。 该字符串的最后一部分指的是PKCS7,这是一个既定标准,用于填充不完全适合块大小的数据。(块是 128 位,并且在加密之前完成填充。)"AES/CBC/PKCS7Padding"

为了完成我们的示例,我们将把这段代码放在一个 encrypt 方法中,该方法将把结果打包到一个HashMap包含加密数据以及解密所需的 salt 和初始化向量中。

private HashMap<String, byte[]> encryptBytes(byte[] plainTextBytes, String passwordString)
{
    HashMap<String, byte[]> map = new HashMap<String, byte[]>();
    
    try
    {
        //Random salt for next step
        SecureRandom random = new SecureRandom();
        byte salt[] = new byte[256];
        random.nextBytes(salt);

        //PBKDF2 - derive the key from the password, don't use passwords directly
        char[] passwordChar = passwordString.toCharArray(); //Turn password into char[] array
        PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256); //1324 iterations
        SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHMacSHA1");
        byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded();
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");

        //Create initialization vector for AES
        SecureRandom ivRandom = new SecureRandom(); //not caching previous seeded instance of SecureRandom
        byte[] iv = new byte[16];
        ivRandom.nextBytes(iv);
        IvParameterSpec ivSpec = new IvParameterSpec(iv);

        //Encrypt
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
        byte[] encrypted = cipher.doFinal(plainTextBytes);

        map.put("salt", salt);
        map.put("iv", iv);
        map.put("encrypted", encrypted);
    }
    catch(Exception e)
    {
        Log.e("MYAPP", "encryption exception", e);
    }

    return map;
}

解密方法

您只需要将 IV 和 salt 与您的数据一起存储。虽然盐和 IV 被认为是公开的,但请确保它们不会按顺序递增或重复使用。要解密数据,我们需要做的就是将Cipher 构造函数中的模式从更改ENCRYPT_MODE为DECRYPT_MODE。 

解密方法将采用HashMap包含相同所需信息(加密数据、盐和 IV)的 a byte[],并在给定正确密码的情况下返回解密后的数组。解密方法将从密码重新生成加密密钥。永远不应该存储密钥!

private byte[] decryptData(HashMap<String, byte[]> map, String passwordString)
{
    byte[] decrypted = null;
    try
    {
        byte salt[] = map.get("salt");
        byte iv[] = map.get("iv");
        byte encrypted[] = map.get("encrypted");

        //regenerate key from password
        char[] passwordChar = passwordString.toCharArray();
        PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256);
        SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
        byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded();
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");

        //Decrypt
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
        decrypted = cipher.doFinal(encrypted);
    }
    catch(Exception e)
    {
        Log.e("MYAPP", "decryption exception", e);
    }

    return decrypted;
}

测试加密和解密

为了使示例简单,我们省略了错误检查,以确保HashMap包含所需的键、值对。我们现在可以测试我们的方法以确保数据在加密后被正确解密。

//Encryption test
String string = "My sensitive string that I want to encrypt";
byte[] bytes = string.getBytes();
HashMap<String, byte[]> map = encryptBytes(bytes, "UserSuppliedPassword");

//Decryption test
byte[] decrypted = decryptData(map, "UserSuppliedPassword");
if (decrypted != null)
{
    String decryptedString = new String(decrypted);
    Log.e("MYAPP", "Decrypted String is : " + decryptedString);
}

这些方法使用byte[]数组,以便您可以加密任意数据而不仅仅是String对象。 

保存加密数据

现在我们有了一个加密byte[]数组,我们可以将它保存到存储中。

FileOutputStream fos = openFileOutput("test.dat", Context.MODE_PRIVATE);
fos.write(encrypted);
fos.close();

如果您不想分别保存 IV 和 salt,则可以使用and类HashMap进行序列化。ObjectInputStreamObjectOutputStream

FileOutputStream fos = openFileOutput("map.dat", Context.MODE_PRIVATE);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(map);
oos.close();

将安全数据保存到SharedPreferences

您还可以将安全数据保存到应用程序的 SharedPreferences.

SharedPreferences.Editor editor = getSharedPreferences("prefs", Context.MODE_PRIVATE).edit();
String keybase64String = Base64.encodeToString(encryptedKey, Base64.NO_WRAP);
String valueBase64String = Base64.encodeToString(encryptedValue, Base64.NO_WRAP);
editor.putString(keyBase64String, valueBase64String);
editor.commit();

由于SharedPreferences是一个仅接受特定原语和对象作为值的 XML 系统,因此我们需要将我们的数据转换为兼容的格式,例如String对象。Base64允许我们将原始数据转换为String仅包含XML 格式允许的字符的表示形式。加密密钥和值,因此攻击者无法弄清楚值可能是什么。 

在上面的示例中,encryptedKey和encryptedValue都是从我们的方法byte[]返回的加密数组。encryptBytes()IV 和盐可以保存到首选项文件中或作为单独的文件。要从 中取回加密字节SharedPreferences,我们可以对存储的String.

SharedPreferences preferences = getSharedPreferences("prefs", Context.MODE_PRIVATE);
String base64EncryptedString = preferences.getString(keyBase64String, "default");
byte[] encryptedBytes = Base64.decode(base64EncryptedString, Base64.NO_WRAP);

从旧版本中清除不安全的数据

既然存储的数据是安全的,那么您的应用程序的早期版本可能会不安全地存储数据。在升级时,数据可能会被擦除并重新加密。以下代码使用随机数据擦除文件。 

理论上,您可以通过删除/data/data/com.your.package.name/shared_prefs/your_prefs_name.xml和your_prefs_name.bak文件并使用以下代码清除内存中的首选项来删除您的共享首选项:

getSharedPreferences("prefs", Context.MODE_PRIVATE).edit().clear().commit();

但是,与其尝试擦除旧数据并希望它有效,不如首先对其进行加密!对于经常将数据写入分散到不同区域以防止磨损的固态驱动器,通常尤其如此。这意味着即使您覆盖文件系统中的文件,物理固态内存也可能会将您的数据保存在磁盘上的原始位置。

public static void secureWipeFile(File file) throws IOException
{
    if (file != null && file.exists())
    {
        final long length = file.length();
        final SecureRandom random = new SecureRandom();
        final RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rws");
        randomAccessFile.seek(0);
        randomAccessFile.getFilePointer();
        byte[] data = new byte[64];
        int position = 0;
        while (position < length)
        {
            random.nextBytes(data);
            randomAccessFile.write(data);
            position += data.length;
        }
        randomAccessFile.close();
        file.delete();
    }
}

结论

这结束了我们关于存储加密数据的教程。在这篇文章中,您学习了如何使用用户提供的密码安全地加密和解密敏感数据。当您知道如何操作时,这很容易做到,但遵循所有最佳实践以确保您的用户数据真正安全非常重要。


文章目录
  • 基础知识
  • 使用密码保护用户数据
    • AES 和基于密码的密钥派生
    • 初始化向量
    • 加密数据
    • 解密方法
    • 测试加密和解密
  • 保存加密数据
    • 将安全数据保存到SharedPreferences
    • 从旧版本中清除不安全的数据
  • 结论