在Android上安全地存储数据

今天,应用程序的信誉高度取决于如何管理用户的私人数据。 Android堆栈具有许多围绕凭据和密钥存储的强大API,并且某些特定功能仅在某些版本中可用。

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

基础

要考虑的第一个问题是您实际上需要获取多少数据。 一个好的方法是避免在不需要时存储私有数据。

对于必须存储的数据,Android体系结构随时可以提供帮助。 从6.0 Marshmallow开始,默认情况下会为具有此功能的设备启用全盘加密。 应用程序保存的文件和SharedPreferences使用MODE_PRIVATE常量自动设置。 这意味着只能由您自己的应用程序访问数据。

坚持默认设置是一个好主意。 保存共享首选项时,可以显式设置它。

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();

避免将数据存储在外部存储上,因为其他应用程序和用户可以看到这些数据。 实际上,为了使人们更难以复制应用程序的二进制文件和数据,可以阻止用户将应用程序安装在外部存储上。 在清单文件中添加一个值为internalOnly android:installLocation即可实现。

您还可以防止备份应用及其数据。 这也可以防止使用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类保证生成的输出将很难预测-它是“具有强大加密功能的随机数生成器”。 现在,我们可以将salt和密码放入基于密码的加密对象: 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)。 一次对我们的数据执行一个块。 此模式的优点在于,每个下一个未加密的数据块都与前一个加密的块进行XOR运算 ,以使加密更加牢固。 但是,这意味着第一个块从未像其他所有块一样独特!

如果要加密的消息与另一个要加密的消息开始时相同,则开始的加密输出将是相同的,这将为攻击者提供线索以弄清该消息可能是什么。 解决方案是使用初始化向量(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/CBC/PKCS7Padding" 这指定了具有密码块链接的AES加密。 该字符串的最后一部分指的是 PKCS7,它是填充数据的既定标准,该数据并不完全适合块大小。 (块为128位,填充在加密之前完成。)

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

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和盐与数据一起存储。 尽管盐和IV被认为是公开的,但请确保它们没有顺序增加或重复使用。 要解密数据,我们要做的就是将Cipher构造函数中的模式从ENCRYPT_MODE更改为DECRYPT_MODE

解密方法将采用一个HashMap ,其中包含相同的必需信息(加密的数据,salt和IV),并给出正确的密码,返回解密的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,则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格式允许的字符。 加密密钥和值,以使攻击者无法弄清楚值的用途。

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

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.xmlyour_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();
    }
}

结论

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

翻译自: https://code.tutsplus.com/tutorials/storing-data-securely-on-android--cms-30558

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值