likes
comments
collection
share

[安全] 聊聊App中的加解密

作者站长头像
站长
· 阅读数 8

I. 前言

之所以写这文章, 主要是有两个原因

1). 移动平台上的安全技术一直在变化. 这种变化就让一些网上的资料变得不再有用, 但少接触这块的人就不知道哪些没用, 为何没用, 以及要用什么新方法. 以Android为例, 先是Android 4.0 引入了KeyChain, Android 4.3引入了keyStore并使用了新的生成Key的类, Android 6.0引入了更多算法, .... 这些变化都让API调用有所不同, 所以要特别加以小心.

2). 网上关于加解密的好资料较少. 你可能确实见过不少介绍KeyStore的文章, 但说句实话, 实用性差一些, 或者说没有说明什么场景适合什么样的加解密. 这篇文章就是想做为一盏在加解密上的指路明灯.

另外, 安全这一块的东西很大的. 围绕着数据与金钱, 黑客与app开发的来回拉锯战, 说多少篇文章都说不完. 所以本文就只聚焦在加解密这一块, 主要就是一些敏感信息, 或是重要信息, 需要加密存储的场景.

再就是一些坑, 我踩过, 在这也写出来, 希望你就不用再踩上了.

重要说明

重要说明: 本文的做法全是基于minSDK >= 23的. 要是你的app minSDK比23小, 那建议你等minSDK升级了你再回来看这文章. 过于老旧的安全加解密做法我就不介绍了, 时效性上差一些, 没什么太多价值了.

备注: 根据2024年3月份的数据, OS分布图

[安全] 聊聊App中的加解密

98.4%的手机都已经超过了api level 23了, 所以我个人觉得可以放心升级minSDK了. 会带给你更多更新更安全的API, 你值得拥有.

坑1: Android文档并不完全对

当你在看Android一些文档时, 你会发现Android官方文档也不见得就一定对. 比如说Cipher这文章

[安全] 聊聊App中的加解密

乍一看, AES-128与AES-256只有在26+才支持是吧? : 但实际上我实际操作了下, 只要minSDK >= 23 (即sdk >= 23才会有 KeyGenParameterSpec这个类), AES-256也行.

下图就是我的代码, 运行在Android Api Level 24上, 运行没问题, 成功加解密了:

[安全] 聊聊App中的加解密

那你可能要说了: 那官方文档都不对, 我还能咋办? 我也不知道哪对哪不对啊? : 我的建议时, 只要你的minSDK >= 23, 基本上本文介绍的内容就已经基本够用了. 以后要是发现有什么再不对的文档 , 我再更新本文哈

II. 本地keystore

下面就开始进入实战了. 第一个使用场景就是: 后台下发了一些敏感数据, 如:

  • 用户的账户余额,
  • 用户的信用卡卡号与背面的CVV码(拿这数据就可以去网上支付了)
  • ...

这种场景即是说后台给了你一些敏感数据, 你本地使用还不够, 还要求存储起来 (比如说这样到了offline的无网环境下也能使用), 这时就要加密存储了.

方法一: EncryptedSharedPreferences

EncryptedSharedPreferences 就是一个方法, 你可以用它来加密一些重要数据.

缺点就是要再学习下这个库, 有点学习成本. 像一些master key, key scheme都得再学习.

方法二: 使用AES加密

另一种方式, 是自己使用AES来加解密. 这更适合熟悉现有加密知识的同学, 使用基本的AES加密就能解决问题.

KeyStore

Android有一个KeyStore的体系. 它一般跟手机上的硬件相结合, 它的安全性好, 很适合存我们算法的Key这种敏感并重要的信息.

创建key


val AES_KEY_ALIAS = "aes_key_001"
val provider = "AndroidKeyStore"


private fun createKeyIfNull(): SecretKey {
    var key = keyStore.getKey(AES_KEY_ALIAS, null) as? SecretKey//第二参为password
    if (key != null) {
        return key
    }

    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, provider)
    val purpose = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    val keyBuilder = KeyGenParameterSpec.Builder(AES_KEY_ALIAS, purpose)
        .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) //并没有pkcs5的常量哦!
        .setKeySize(256) //这才是设置key size的正确之地 ;  若不设置, 那builder的keySize就是-1
    val builder = keyBuilder.build()
    keyGenerator.init(builder)

    //其实不用返回也行. 要用时自己去keystore取即可.
    key = keyGenerator.generateKey()
    return key
}

说白了, 上面就是指定了

  • AES算法为: "AES/CBC/Pkcs7"
  • key size = 256
  • KeyStore类似于一个hashMap<String name, SecretKey secretKey>. 而这个AES_KEY_ALIAS就是string map (类似k-v对中的k), 用来取出对应的SecretKey来(类似k-v对中的v).
  • 只生成一次. 若已经生成了, 就直接返回key就行了, 不用再生成了.

加密

这时就可以使用Cipher来加密了. 注意也要指明"AES/CBC/Pkcs7Padding"


    val algorithm = "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}"

    val key = createKeyIfNull()
    val cipher = Cipher.getInstance(algorithm)
    cipher.init(Cipher.ENCRYPT_MODE, key)
    val encrypted: ByteArray = cipher.doFinal(plaintext.toByteArray()) //plaintext就是要被加密的敏感信息
    val iv: ByteArray = cipher.iv // 向量. (后面再介绍)

解密

val key = createKeyIfNull()
val cipher = Cipher.getInstance(algorithm)

cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) //新加了个第三参iv
val decrypted: ByteArray = cipher.doFinal(encrypted)
val resultString = String(decrypted, Charsets.UTF_8)

println("解密后 = ${resultString}")

就得拿到了上面加密的结果, 以及向量iv后, 用cipher就能解密成功了.

III. 介绍AES

上面没有先讲AES, 直接加了如何生成key, 并加密解密. 主要是怕理论知识过多, 太过于枯燥, 所以先给了使用例子. 现在我们就可以讲下AES加密算法, 希望让你更有印象.

加密算法主要是分两种:

  • 对称加密: 即加密与解密方用的key是一样的, 在java里是用SecretKey类来代表的. 相关的算法有: DES, AES, ....
  • 非对称加密: 即加密方与解密方用的key是不一样的, 但仍能成功解密. 这种一般是一个公钥, 一个私钥. 相关算法有: RSA, ....

模式 (mode)

最常见的两种模式(mode)就是:

  • ECB
  • CBC

一些开发十来年的代码怕是还有些老代码在使用ECB, 其实现在Android Studio都会提示, 让我们不要再使用ECB了, 不安全.

[安全] 聊聊App中的加解密

ECB不安全的原因就是: 同样的key与原文(plain text), 每次加密后的结果是一样的. 这样黑客就更容易攻克.

相较之下, CBC更安全. CB就是引入了一个叫做"向量"(iv)的东西. IV的长度与AES的分块一样, 一般也是128 bits (即16 bytes). 每次CBC加密时, 即使key与plainText一样, 只要iv不一样, 加密的结果就不一样. 这样就更难攻陷些.

填充(padding)

AES本质上就是分块来加密的. 其实想一想也在理, 加密的文本可能很大, 为了提升加密速度, 我们肯定是希望分成多个块, 同时加密多个块, 这样加密效率就起来了.

上面讲过的, iv的长度与AES块一样大, 就是和这里对上了.

但注意, 要是你的plainText的大小正好是块大小的整数倍, 那敢情好, 没一点问题. 不过极大概率的是, plainText的大小不是块大小在的整数倍, 这时就需要使用填充, 来让不足的部分也能变成块大小的size.

常见的padding有:

  • NoPadding: 这样plainText的大小不是块大小的整数倍时, 这种模式就会让加密失败, 并crash
  • Pkcs5Padding
  • Pkcs7Padding

Pkcs7Padding, Pkcs5Padding

戏肉来了: 当你想多端(Android, iOS, 后台, web...)协作时, padding就会有问题. 比如说: Android端只有Pkcs5Padding, iOS中只有Pkcs7Padding. 这时如何协作?

若是按这样写, 我们是不可能说后台加密好, 然后Android,iOS端都能解密成功的. : 但其实不然. 这篇文章, <pkcs5, pkcs7区别>其实介绍得很好, 就是Pkcs7其实是兼容Pkcs5的.

所以当涉及多端合作时

  • 后台加密, 直接使用pkcs7
  • iOS端解密, 用Pkcs7即可
  • Android端解密, 用pkcs5也能成功解密的

key size

AES的key, size只能是128/196/256 bit哦, 要是size不对, 加解密就会crash:

[安全] 聊聊App中的加解密

IV. 异地场景的加解密

要如何才能多端协作?

上面的场景II, 更适合于本地加解密. 即数据就在本地加密, 也在本地解密. 所以使用KeyStore最好了, 对key的保护最到位.

但现实中也有很多很多场景, 是要求多端协作的. 比如

  • 后端加密一个敏感信息, 传给客户端. 客户端要解密后展示给用户看
  • 或是, 客户端加密一个敏感数据, 把加密结果传给后台; 后台自己解密并存在自己的数据库里.

这种异地场景, 就需要我们传递关键的信息. 这样你一端加密了, 在另一端才能成功解密. 那这个关键的信息是什么呢? : 简单来说就是key与向量. 或者分开来说:

  • 对于ECB模式, 它是没有IV的, 只传递key给另一端就行了
  • 对于CBC模式, 它是有IV的. 所以要传递key与IV两种数据才行.

key.encode为空

现在我们仍使用场景II中的代码, 使用KeyStore生成了key, 然后我们Android端加密后, 把加密结果(把byte[]转成了Base64的String, 或Hex的String), 以及Key给后端. 后端就能解密了.

但麻烦出来了:

val key = createKeyIfNull()
println("key = ${key.encoded}") //=> null

我们想得到生成的key, 结果却是一个null.

这个其实是KeyStore故意这样的, 因为它的key都是很安全的, 你只能用, 但不能得到它的详情, 更不能得到它的encoded来再组装一个相同的key.

好吧, 这样一来, KeyStore确实更安全, 但它根本不适合于多端协作啊. 多端协作我们就要传递这两个给后台

  • encrypted的结果 (可以是Base64的格式)
  • key
  • (可能还需要 IV)

而key现在具象化失败, 于是没法传递给后台, 就无法多端协作了.

那怎么办?

这时就只能是固定一个key, (可能还要一个IV), 这样三端都能收到一样的key与iv. 这样一端加密, 另外两端才能成功解密.

具体到Android端, 就是不能使用KeyStore了, 得使用SecretKeySpec了. 比如说:

    val raw = "0123456789abcdef".toByteArray()
    val key = SecretKeySpec(raw, "AES")
    val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
    cipher.init(Cipher.ENCRYPT_MODE, key)

即我们使用一个utf字符串, 通过SecretKeySpec为得到一个真正的SecretKey对象. 而不是KeyStore中生成Key了.

再提 key size

注意, 这时的key就是16 byte, 即128 bit. 如上面所说, 要是key的size不对, 比如说: val raw = "01234".toByteArray(), 那加密就crash了:

[安全] 聊聊App中的加解密

实例ecb(三端)

现在后台下发一个key, 我们客户端加密一段敏感信息后, 把加密结果再传给后台供它解密.

Android端

我们先请求后台一个接口, 得到了key是:

val raw = "01cd796-2466-4f9"

有了key我们就可以加密了:

val key = SecretKeySpec(raw.toByteArray(), "AES")
val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val keyBytes: ByteArray = cipher.doFinal("hello world".toByteArray()) //原文现在假设为"hello world"
val key64 = Base64.encodeToString(keyBytes, Base64.DEFAULT)
api.postEcb( CipherText(key64) ) //把加密结果传给后台.  
// 注意ECB模式没有向量, 所以没有传iv.

备注: CipherText类是我自己定义的:

data class CipherText(val text64: String, val iv64: String = "")

Fluttter端

要是使用Flutter, 那就要使用encrypt这个三方库.

encrypt这个库好就好在使用起来比Android与crypto-js要更方便, 特别是编码这一块. 你可以直接让key从base64, 或是从hex, 或是固定长度, 或是其它格式中生成出来. IV也一样支持多种格式的生成.

import 'package:encrypt/encrypt.dart' as Crypto;

final key = Crypto.Key.fromUtf8(keySrc);  //从uft-8字串中生成key
final crypto = Crypto.Encrypter(Crypto.AES(key, mode: Crypto.AESMode.ecb, padding: 'PKCS7'));
final encrypted = crypto.encrypt(plaintText);
String encrypted64 = encrypted.base64 //把这个加密后的Base64串给后台

这个代码量比Android的要少, 同时比crypto-js要清楚得多(可读性好), 这个encrypt库还是蛮好用的.

后台(node.js)

后台使用了Node.js, Express, 以及引入了三方库crypto-js

var CryptoJS = require("crypto-js");

app.post("/aes/ecb", (req, resp) => {
    let ciphertext = req.body.text64; //base64格式 
    let raw = "01cd796-2466-4f9" //真实世界中这个key是有一定要求的, 后面再详细讲
    var bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(keySrc), {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7});
    var originalText = bytes.toString(CryptoJS.enc.Utf8); //=>  hello world
});     

crypto-js库最麻烦的点就在于编码.

注意哦, JS中没有byte一说, ES6才有了ArrayBuffer. 而Crypto-JS早于ES6就有了, 所以crypto-js有自己的底层实现, 叫做WordArray来达到类似bytes的效果.

当然, 像bytes一样, WordArray不太友好(不适合人读), 所以Crypto-JS就有了各种编码: Utf8, Hex, Base64, ....

像上面的代码, 我们的Key是utf-8格式的, 所以我们就要就是把这个utf-8字串脱壳, 从String脱成WordArray, 也就是上面的CryptoJS.enc.Utf8.parse(keySrc)

说明

总结下

  • 后台下发key给客户端
  • 客户端拿着key, 加密完敏感信息, 再把密文传给后台
  • 后台拿着密文, 自己再取出key, 就能解密了.

不过ECB不太安全. 要是你不是在维护老代码, 而是写新代码, 这里就推荐使用CBC了.

实例cbc(三端)

什么是向量?

或者说, 为什么ECB不安全?

: 答案其实是一个, 就是ECB模式下, 只要key与plain text一样, 那加密N次的结果都是一样的. 这样就给了黑客固定的靶子, 更方便他们破解.

解决方式之一就是引入向量. 你可以理解向量为另一个key, 或者叫做salt也行. 就是加密时, 输入不再是原文与key, 输入现在还包括向量. 所以向量的size与AES的分块大小一样, 都是128bit.

这样一来, 只要每次向量不一样, 即使key与plain text一样, 那加密多次, 加密的结果即不一样. 这样就增加了黑客破解的难度了.

实践准则

当使用CBC模式时, 我们会额外多一个向量. 这时我们传给后台的不能只是密文了, 我们要把向量(IV)也传给后台. 这样后台拿着密文与IV, 才能成功解密.

p.s. IV并不建议写死哦, 那就失去了随机的意思. 最好的方式也是和key一样, 是每个用户不一样. 这样才增加了破解的难度. 如何设置好key与IV, 我们在最后一节中会讲到.

Android端

val raw = "01cd796-2466-4f9"
val key = SecretKeySpec(raw.toByteArray(), "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") //变化1
cipher.init(Cipher.ENCRYPT_MODE, key)
val keyBytes: ByteArray = cipher.doFinal("hello world".toByteArray()) 
val key64 = Base64.encodeToString(keyBytes, Base64.DEFAULT)
val iv64 = Base64.encodeToString(cipher.iv, Base64.DEFAULT) //变化2
api.postEcb( CipherText(key64, iv64) 

相较于ECB加密, CBC主要就是两点不同 (都在上面代码中注释出来了)

1). 变化1 : 即模式从ECB改成了CBC

2). 变化2 : ECB没有IV, 但CBC中有. 所以我们要从cipher.iv中得到这个随机生成的IV, 并和密文一起传给后台, 这样后台才能解密成功.

Fluttter端

import 'package:encrypt/encrypt.dart' as Crypto;

final key = Crypto.Key.fromUtf8(keySrc);  
final iv = Crypto.IV.fromSecureRandom(16); // 变化1
final crypto = Crypto.Encrypter(Crypto.AES(key, mode: Crypto.AESMode.CBC, padding: 'PKCS7')); // 变化2
final encrypted = crypto.encrypt(plaintText, iv: iv); //变化3

// 把这两个传给后台
String encrypted64 = encrypted.base64
String iv64 = iv.base64;

Flutter的代码仍是最简洁的. 就是

  • 先使用random的16 bytes来生成IV.
  • 然后加密时把IV传入
  • 最后发给后台密文与IV.

后台(node.js)

后台拿到了密文与IV, 进行解密

let cipher64 = req.body.text64 //=> "ySjrACx+Q+MNyKgKVyJxOA=="
let iv64 = req.body.iv64 //=> "uR3S7v+tAYP6SN57Do2UuQ=="
let keySrc = "01cd796-2466-4f9"
var bytes = CryptoJS.AES.decrypt(cipher64, CryptoJS.enc.Utf8.parse(keySrc),
        {mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7, iv: CryptoJS.enc.Base64.parse(iv64)});
var originalText = bytes.toString(CryptoJS.enc.Utf8);

这个和ECB模式的解密 基础科一样, 就不多讲了. 就是多了一个IV而已.

V.安全实践

key如何才能安全传输?

上面讲到了, 当需要有多端协作(比如说: 一端加密, 另外几端解密)时, 我们就要传递key (甚至是IV) 了.

但直接明明白白地传递一个key给所有用户的做法是很不安全的. 因为AES是对称加密, 黑客要是拿到了key, 在这情况下就相当于拿到了所有用户的key, 那黑客就可以拿着这个key去搞别人的数据了.

所以最好的实践原则是: 每个用户的key是不一样的 !!! 比如说: key是user token的一部分, 或是session ID的一部分 (我们假设每次自动登录叫做一次session). 后台可以给我们一个:

{
    "token": "501cd796-2466-4f9c-b610-d2b56e964b6c",
    "a": 3,
    "b": 19,
}

这样我们用户在打开app时, 每次自动登录都能得到一个新token. 而且用户与用户之间, 各自的token是不一样的. 然后我们客户端就可以这样取出key:

// val raw = "01cd796-2466-4f9" //不再这样直白了, 改从response中取
val resp = response.data;
val raw = resp.token.substring(resp.a, resp.b) 
val key = SecretKeySpec(raw.toByteArray(), "AES")

这样就能保证key是每个用户不一样, 这样才最大限度地保护了AES这种对称加密.

IV呢?

上面讲过, 只要IV一样, 那每次CBC加密的加密结果也是一样的. 所以IV也要做到每次加密都不一样, 而且最好每个用户的IV还不一样.

好在和上面的key传输原则类似, 我们也可以利用token这些来达到这两个目的:

  • 每次加密的IV不一样,
  • 而且不同用户的IV也不一样

这时后台可以传递:

{
    "token": "501cd796-2466-4f9c-b610-d2b56e964b6c",
    "a": 3,
    "b": 19,
    "c": 12, 
    "d": 28,
}

然后我们客户端就可以这样取出key与IV:

// val raw = "01cd796-2466-4f9" //不再这样直白了, 改从response中取
val resp = response.data;

val rawKey = resp.token.substring(resp.a, resp.b) 
val key = SecretKeySpec(rawKey.toByteArray(), "AES")

val rawIv = resp.token.substring(resp.c, resp.d)
val iv =  IvParameterSpec(rawIv.toByteArray())

备注: 其实现实生活中直接给key, iv也行 (即不再使用a,b,c,d这几个flag位), 只要key与iv是每个用户不一样, 安全性就能得到保证.

VI. 总结

网上其实很少讲到我上面的内容, 要么就是老式的资料, 要么就是只说KeyStore, 完全不提在多端协作时KeyStore不太有用. 所以我总结下就是:

  • 若敏感数据只在本地存储与读取, 不需要传输给其它端 (后台, iOS, web, ..), 那请使用KeyStore来generate key. 这样的安全性最好.

  • 若需要多端协作(一端加密, 其它端解密), 那KeyStore不适合. 请使用SecretKeySpec来生成key, 这样能保证多端的key是一样的, 才有解密成功的可能性.


而关于AES加密的一些关键点, 总结下就是:

  • 因为AES是对称加密, 所以每个用户的key应该不一样. 这样才能防止破解了一个用户的key, 就能破解所有用户的key的问题. (IV同理)

  • IV不要写死在代码里, 最好是变化的, 是随机的, 是每个用户不一样的. 这点和Key一样. 但也请重视IV的随机性才能保证AES的安全性.

  • ECB模式已经不安全了. 新feature需要加密时请使用CBC模式.

  • 最好是应用minSDK >= 23, 不然很多新的API都用不上.


备注: 对于RSA这种安全性更高, 运行起来更慢的非对称加密算法, 就不在本文中讲解了. 若大家对安全这块很感兴趣, 下次可以讲讲RSA的知识.

转载自:https://juejin.cn/post/7360961068166332457
评论
请登录