Hi%用户名%。 无论报告的主题是什么,在会议上都不断问我同样的问题-“如何在用户的设备上安全存储令牌?”。 通常,我会尝试回答,但时间不允许充分揭示主题。 通过这篇文章,我想完全解决这个问题。
我分析了十几个应用程序,以了解它们如何与令牌一起使用。 我分析的所有应用程序都处理了关键数据,并允许我设置输入的个人识别码作为附加保护。 让我们看一下最常见的错误:
- 将PIN码与RefreshToken一起发送到API,以确认身份验证并接收新令牌。 -不好,RefreshToken在本地存储中不安全,可以物理访问设备或备份,可以将其删除,而恶意软件也可以做到这一点。
- 使用RefreshToken将Pin代码保存在消息中,然后对Pin代码进行本地验证,然后将RefreshToken发送到API。 -一场噩梦,RefreshToken和图钉不安全,这使得它们无法被提取,此外,另一个矢量出现,提示绕过本地身份验证。
- 带有密码的错误的RefreshToken加密,这使您可以从密文恢复密码和RefreshToken。 -先前错误的特殊情况,利用起来稍微复杂一些。 但是请注意,这是正确的方法。
在查看了常见错误之后,您可以继续思考在应用程序中安全存储令牌的逻辑。 值得从应用程序运行期间与身份验证/授权关联的基本资产入手,并提出一些要求:
凭据 ((用户名+密码))用于验证系统中的用户。
+密码永远不会存储在设备上,发送到API后应立即从RAM中清除
+不会在HTTP请求的查询参数中通过GET方法传输,而是使用POST请求
+键盘缓存已禁用,无法使用密码处理文本字段
+剪贴板被禁用,用于包含密码的文本字段
+密码不会通过用户界面公开(使用星号),并且密码不会进入屏幕截图
AccessToken-用于确认用户授权。
+从不存储在长期存储器中,仅存储在RAM中
+不会在HTTP请求的查询参数中通过GET方法传输,而是使用POST请求
RefreshToken-用于获取新的AccessToken + RefreshToken捆绑包。
+不会以任何形式存储在RAM中,在从API接收并保存到长期存储器中或从长期存储器接收并使用后,应立即将+从存储器中删除+
+仅以加密形式存储在长期内存中
+使用魔术和某些规则用销子加密(规则将在下面描述),如果未设置销子,则根本不保存
+不会在HTTP请求的查询参数中通过GET方法传输,而是使用POST请求
PIN (通常是4或6位数字)-用于加密/解密RefreshToken。
+从未存储在设备上的任何位置,使用后应立即从RAM中清除
+永远不会离开应用程序限制,这些限制不会在任何地方传输
+仅用于加密/解密RefreshToken
OTP是2FA的一次性代码。
+ OTP永远不会存储在设备上,发送到API后应立即从RAM中清除
+不会在HTTP请求的查询参数中通过GET方法传输,而是使用POST请求
+禁用键盘缓存以处理OTP的文本字段
+剪贴板已禁用,用于包含OTP的文本字段
+ OTP不会进入屏幕截图
+应用程序在进入后台时从屏幕上删除OTP
现在让我们继续进行密码学的
魔力 。 主要要求是,在任何情况下都不应允许实施这样的RefreshToken加密机制,在该机制中可以在本地验证解密结果。 就是说,如果攻击者拥有密文,则他应该不能领取密钥。 唯一的验证者应该是API。 这是在发生蛮力攻击时限制密钥选择尝试和使令牌失效的唯一方法。
我将举一个很好的例子,假设我们要加密UUID
aec27f0f-b8a3-43cb-b076-e075a095abfe
使用这组AES / CBC / PKCS5Padding,并使用PIN作为密钥。 看起来算法不错,一切都基于准则,但是有一个关键点-关键包含很少的熵。 让我们看看这会导致什么:
- 填充-由于我们的令牌占用36个字节,并且AES是具有128位块的块加密模式,因此该算法需要将令牌最多完成48个字节(128位的倍数)。 在我们的版本中,将根据PKCS5Padding标准添加尾部,即 每个添加的字节的值等于添加的字节数
01
02 02
03 03 03
04 04 04 04
05 05 05 05 05
06 06 06 06 06 06
等
我们的最后一块看起来像这样:
... | 61 62 66 65 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C |
还有一个问题,看看这个填充,我们可以过滤掉由错误密钥解密的数据(通过无效的最后一个块),从而从扭曲的堆中确定有效的RefreshToken。 - 令牌的可预测格式-即使我们将令牌设为128位的倍数(例如,删除连字符)以避免添加填充,我们也会遇到以下问题。 问题在于我们可以收集所有相同的扭曲堆,并确定哪一行属于UUID格式。 规范文本形式的UUID是十六进制格式的32位数字,由连字符分隔为5组8-4-4-4-12
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
其中M是版本,N是选项。 所有这些都足以过滤出使用错误密钥解密的令牌,从而留下合适的UUID RefreshToken格式。
鉴于以上所有内容,您可以继续实施,我选择了一个简单的选项来生成64个随机字节并将它们包装在base64中:
public String createRefreshToken() { byte[] refreshToken = new byte[64]; final SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(refreshToken); return Base64.getUrlEncoder().withoutPadding() .encodeToString(refreshToken); }
这是此类令牌的示例:
YmI8rF9pwB1KjJAZKY9JzqsCu3kFz4xt4GkRCzXS9-FS_kbN3-CF9RGiRuuGqwqMo-VxFDhgQNmgjlQFD2GvbA
现在让我们看看它的算法外观(在Android和iOS上,算法是相同的):
private static final String ALGORITHM = "AES"; private static final String CIPHER_SUITE = "AES/CBC/NoPadding"; private static final int AES_KEY_SIZE = 16; private static final int AES_BLOCK_SIZE = 16; public String encryptToken(String token, String pin) { decodedToken = decodeToken(token);
哪些行值得关注:
private static final String CIPHER_SUITE = "AES/CBC/NoPadding";
记住,没有填充。
decodedToken = decodeToken(token);
您不能仅以base64表示形式获取并加密令牌,因为该表示形式具有某种格式(请记住,请记住)。
byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE);
在输出中,我们得到一个大小为AES_KEY_SIZE的密钥,适用于AES算法。 如果pbkdf2寿命很短(在FPGA上并行性很好),则Argon2,SHA-3,Scrypt推荐的任何密钥派生函数都可以用作kdf。
最终的加密令牌可以安全地存储在设备上,而不必担心有人可以窃取它,无论是恶意软件还是不受道德原则影响的实体。
其他一些建议:
- 从备份中排除令牌。
- 在iOS上,使用kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly属性将令牌存储在钥匙串中。
- 不要在整个应用程序中分散本文中讨论的资产(键,密码,密码等)。
- 一旦资产变得不必要,就将其覆盖,不要将它们在内存中的保留时间超过必需的时间。
- 在Android上使用SecureRandom,在iOS上使用SecRandomCopyBytes在加密上下文中生成随机字节。
在存储令牌时,我们检查了一定数量的陷阱,在我看来,开发使用关键数据的应用程序的每个人都应该知道这些陷阱。 如果您有任何疑问,可以在本主题中随时混淆,在评论中提问。 也欢迎对文本发表评论。
参考文献:
CWE-311:缺少敏感数据的加密CWE-327:使用损坏的或有风险的密码算法CWE-327:CWE-338:使用加密弱伪随机数生成器(PRNG)CWE-598:通过GET请求中的查询字符串进行信息公开