尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android加密从Blowfish迁移到AES-GCM:安全选型与实战改造指南

Android加密从Blowfish迁移到AES-GCM:安全选型与实战改造指南 接手过一个老项目里面对用户手机号做加密存储用的就是Blowfish当时第一反应是“这玩意儿还活着呢”查了一圈资料发现Blowfish确实是加密算法界的“老前辈”1993年由Bruce Schneier设计的对称分组密码在那个年代以免费、快速、无专利著称很多老系统、老APP里都有它的影子。但放到今天用Blowfish处理敏感数据尤其是在Android端已经不是“推荐不推荐”的问题而是“应该立刻迁移”的问题。这篇东西我打算聊透三件事Blowfish算法的本质和它在Android上的处境、当下加密算法选型的实际标准、以及如果你手里正好有Blowfish加解密代码该怎么安全地迁移到现代算法。顺便把我在实际项目中踩过的坑一并交代清楚包括Cipher填充模式、密钥管理、兼容性处理这些文档里一般不写的东西。1. 先搞懂Blowfish到底是个什么级别的算法1.1 算法出身与设计初衷Blowfish是一个对称分组加密算法分组长度64位密钥长度32位到448位可变。它的设计者Bruce Schneier设计这个算法的初衷是替代已经老化的DES而且刻意做到了完全开源、无专利限制在当时那个加密算法被出口管制搞得乌烟瘴气的年代Blowfish的开放姿态确实让它迅速铺开。很多早期路由器、嵌入式设备、数据库系统都用它做加密甚至SSH协议在早期版本里也把Blowfish作为可选加密算法。它的结构是Feistel网络16轮迭代核心是依赖密钥的S盒和P数组。所谓“依赖密钥”指的是S盒和P数组不是固定常量而是由密钥本身经过扩展算法动态生成的这也让Blowfish在没有初始化的情况下光密钥扩展这一步就已经有相当的计算量。这个设计在当时是非常聪明的因为它把“密钥调度”和“数据加密”做了强耦合暴力破解的成本因此更高。1.2 Android项目里Blowfish的技术特点在Android上使用Blowfish主要通过javax.crypto.Cipher类来实现底层由Android系统的加密提供者通常是Conscrypt或BC负责具体实现。使用方式大致是这样SecretKeySpec keySpec new SecretKeySpec(keyBytes, Blowfish); Cipher cipher Cipher.getInstance(Blowfish/CBC/PKCS5Padding); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(plainText.getBytes(UTF-8));这段代码看起来很规整但问题恰恰隐藏在“规整”背后。Blowfish本身只定义了分组加密的原始算法不规定你怎么分组、怎么补齐、怎么链接——这些都要由使用方在Cipher.getInstance()的字符串里显式指定。最常见的组合是Blowfish/CBC/PKCS5Padding但也有大量老代码直接用Blowfish/ECB/PKCS5Padding甚至默认的Blowfish后者相当于使用了Provider默认的模式和填充策略在Android不同版本和不同厂商ROM上行为可能不一致。1.3 Blowfish在当前安全体系下的真实评价把话说明白Blowfish到目前为止并没有被彻底攻破针对它的全轮数攻击仍然停留在理论层面实际可利用的攻击面主要还是集中在64位分组大小和特定的使用模式上。64位分组意味着什么意味着在同一个密钥下加密的数据量如果超过约32GB就可能触发生日悖论导致分组碰撞攻击者可以利用碰撞信息推测出明文关系。对移动端单条数据加密来说单次加密量远远达不到这个量级。真正让Blowfish被现代安全体系边缘化的原因有两个一是64位分组的先天劣势在现代协议里被放大二是业界已经找到更好的替代方案没有理由继续用旧的。2016年学术界发布的Sweet32攻击就是针对64位分组密码包括Blowfish、3DES等在CBC模式下的一次现实攻击演示虽然触发条件需要加密大量数据但它证明了64位分组在多场景下确实存在安全隐患。2. 现有加密算法阵营对比选型的底牌到底在哪2.1 主流对称加密算法横向对比做Android开发的选型核心就三类AES、ChaCha20、其余的老将们Blowfish、3DES、DES。我拿实际项目最关心的几个维度做了一张对比表算法分组长度密钥长度模式安全性Android原生支持推荐度Blowfish64位32-448位CBC需额外处理ECB禁用支持不推荐DES64位56位实际已被暴力破解支持禁止3DES64位112/168位有Sweet32风险性能差支持不推荐AES128位128/192/256位GCM模式认证加密安全支持强烈推荐ChaCha20128位流加密256位Poly1305认证性能优Android 9系统级支持推荐这个表放到项目里落实就是一句很直接的话新代码用AES-GCM老代码收尾期可以用AES-CBC做好兼容而Blowfish这条线该断就断。2.2 为什么AES-GCM站在了首选位置AESAdvanced Encryption Standard是NIST在2001年正式标准化的对称加密算法分组长度128位密钥支持128/192/256位。它经受住了二十多年的公开密码分析目前最有效的攻击方式也只是针对减少轮数的变体对完整AES-256没有实质威胁。AES-GCM是AES在GCMGalois/Counter Mode工作模式下的组合GCM模式同时提供机密性和完整性校验。这个特性非常重要——传统CBC模式只保证机密性不保证完整性攻击者可以篡改密文而不被发现。在实际项目中没有完整性校验的加密方案几乎等于裸奔因为攻击者可以通过修改密文来猜测明文结构经典的Padding Oracle攻击就是这么打的。GCM模式通过内部的GHASH函数和额外的认证标签来保证任何一位密文被修改都能被接收方识别省掉了额外引入HMAC的麻烦。从Android实现角度来说AES-GCM有标准API支持Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);这里要强调一点GCM模式要求每次加密必须使用不同的IV12字节推荐长度否则安全性归零。这个限制在写法上很容易被忽略后面我会专门讲。2.3 ChaCha20-Poly1305是备胎还是第二选择ChaCha20是Daniel J. Bernstein设计的流加密算法后来被RFC 7539标准化为ChaCha20-Poly1305组合在TLS 1.3中被列为强制推荐的加密套件之一。它在软件实现上的性能非常出色特别是没有AES硬件加速的旧设备上ChaCha20通常比AES-GCM快不少。Android从API 28Android 9开始系统级支持ChaCha20-Poly1305可以通过Cipher.getInstance(ChaCha20-Poly1305/None/NoPadding)直接调用。如果你的minSdkVersion在28以上ChaCha20-Poly1305是一个和AES-GCM同级别的可靠选择。但现实情况是大多数项目的minSdkVersion还在23到26之间这时候就需要引入第三方加密库或者直接用AES-GCM因为javax.crypto.Cipher在API 28以下拿不到ChaCha20的实现。2.4 不应该再碰的“传统方案”DES和3DES没有任何继续使用的理由。DES的56位密钥在1999年就被Deep Crack用不到一天的时间暴力破解了属于已经被世界淘汰的产物。3DES虽然把有效密钥长度提升到了112或168位但它的分组长度依然是64位Sweet32攻击对它依然有效而且3DES的性能极差因为它的轮数是三个DES叠加。我在代码审查里看到有人为了兼容老接口还在用3DES回答一贯是尽快安排接口升级老数据做一次性迁移新数据一律走AES。3. Android实操Blowfish加解密代码怎么替换成AES-GCM3.1 原来的Blowfish加密逻辑长什么样为了把迁移过程讲清楚我先给出一段典型的老代码。这种代码在GitHub和各类博客里到处都是也是很多项目在照抄之后留下来的历史包袱public class BlowfishEncryptor { private static final String KEY myStaticKey; private static final String ALGORITHM Blowfish/CBC/PKCS5Padding; private static final byte[] IV 12345678.getBytes(StandardCharsets.UTF_8); public static String encrypt(String plainText) throws Exception { SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), Blowfish); IvParameterSpec ivSpec new IvParameterSpec(IV); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.encodeToString(encrypted, Base64.NO_WRAP); } public static String decrypt(String encryptedText) throws Exception { SecretKeySpec keySpec new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), Blowfish); IvParameterSpec ivSpec new IvParameterSpec(IV); Cipher cipher Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted cipher.doFinal(Base64.decode(encryptedText, Base64.NO_WRAP)); return new String(decrypted, StandardCharsets.UTF_8); } }我挑几个问题说密钥写死在代码里这是最严重的问题。APK是可以用工具解包的硬编码密钥等于把保险柜钥匙贴在保险柜外面。IV固定且无法校验CBC模式的IV应该随机生成每次加密都重新生成固定IV会让相同的明文在相同密钥下生成完全相同的密文这给攻击者提供了可利用的模式。没有完整性校验CBC模式下密文被篡改解密端无法直接察觉这在转账、登录态、用户资料等敏感场景是致命的。3.2 一套可落地的AES-GCM改造方案迁移之后的代码我提供一个能直接放进生产项目的版本。这个版本解决了密钥管理、IV随机性、完整性校验三个核心问题public class AesGcmEncryptor { private static final String TRANSFORMATION AES/GCM/NoPadding; private static final int IV_SIZE 12; // GCM推荐12字节 private static final int TAG_SIZE_BITS 128; // 密钥不要硬编码从环境变量或远程配置中心读取 private final SecretKey key; public AesGcmEncryptor(String base64Key) { byte[] keyBytes Base64.decode(base64Key, Base64.NO_WRAP); this.key new SecretKeySpec(keyBytes, AES); } public String encrypt(String plainText) throws Exception { byte[] iv new byte[IV_SIZE]; SecureRandom random new SecureRandom(); random.nextBytes(iv); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_SIZE_BITS, iv)); byte[] cipherText cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 输出格式IV CipherText 拼接后Base64方便解密时拆分 ByteBuffer buffer ByteBuffer.allocate(iv.length cipherText.length); buffer.put(iv); buffer.put(cipherText); return Base64.encodeToString(buffer.array(), Base64.NO_WRAP); } public String decrypt(String encryptedData) throws Exception { byte[] decoded Base64.decode(encryptedData, Base64.NO_WRAP); ByteBuffer buffer ByteBuffer.wrap(decoded); byte[] iv new byte[IV_SIZE]; buffer.get(iv); byte[] cipherText new byte[buffer.remaining()]; buffer.get(cipherText); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_SIZE_BITS, iv)); byte[] plainBytes cipher.doFinal(cipherText); return new String(plainBytes, StandardCharsets.UTF_8); } }这套方案的核心逻辑是每次加密生成12字节的随机IV并且把IV拼在密文前面一起返回。解密时先拆出IV再解密。这样服务端或客户端解密的唯一前置条件就是有同一个密钥不需要额外同步IV大大降低了对接复杂度。3.3 为什么输出格式要设计成“IV密文”一体很多人写GCM代码时会犯一个错误直接调用cipher.doFinal()拿密文然后把IV单独通过另一个字段传。这么做不是不行但增加了出错的概率——比如有的框架会把IV放在日志里、有的会把IV存数据库字段导致长度不对。把IV和密文拼成一个字符串接收端一次解析就能完成这是生产环境下最不容易出错的做法。我处理跨端对接时习惯在接口文档里明确写清楚这个格式Base64( IV(12字节) CipherText AuthTag(16字节) )这样无论服务端是Java还是Go还是Python拼包拆包逻辑都不会出现歧义。4. 迁移Blowfish老代码时最容易踩的坑4.1 旧数据怎么读CBC模式的兼容层怎么搭如果你手里已经有一批用Blowfish加密的存量数据用户在数据库里的手机号、身份证号等等直接换AES-GCM会导致老数据全部变成乱码正确的做法是设计一个双读阶段。迁移步骤我建议这样安排发布新版APP包含两个解密器AES-GCM解密器负责新数据原有的Blowfish解密器保留只用来读老数据。在数据层加一个版本标识字段比如enc_version值为1表示旧Blowfish值为2表示AES-GCM。用户每次访问数据时如果发现enc_version是1就用Blowfish解密拿到明文然后立刻用AES-GCM重新加密并更新enc_version为2。跑一段时间监控数据库里enc_version1的记录数量降到0之后在下一次发版时彻底删除Blowfish解密代码。这里要注意读取老数据时Cipher.getInstance()的算法字符串必须和当初加密时完全一致特别是模式和填充方式。如果老数据加密时用的是Blowfish/CBC/PKCS5Padding解密就必须是同一个字符串少一个斜杠、大小写写错都会直接抛BadPaddingException。4.2 密钥轮换不要把所有鸡蛋放在一个篮子里密钥管理是所有加密方案里最容易被低估的环节。我看到很多项目把密钥放在代码里或者放在SharedPreferences里这等于没有加密。Android平台上的标准做法是把密钥放到Android Keystore系统里让密钥在硬件安全模块TEE或StrongBox中生成和存储应用只能拿到密钥的引用无法直接导出密钥本身。Android Keystore使用方式如下KeyGenerator keyGenerator KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore); KeyGenParameterSpec spec new KeyGenParameterSpec.Builder( my_app_aes_key, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .build(); keyGenerator.init(spec); SecretKey key keyGenerator.generateKey();用这种方式生成的密钥即便APP被Root、APK被逆向攻击者也拿不到原始密钥材料。真实项目中我见过因为密钥硬编码导致大量用户数据被拖库后直接脱裤的案例花半天时间把密钥迁到Keystore收益是翻倍的。如果因为某些原因需要对老项目做快速补救至少把密钥从代码里抽到本地配置文件并做混淆处理同时尽快排期迁移到Keystore。4.3 字符编码一个低级但致命的错误在使用String.getBytes()时如果没有指定字符集在Android上默认使用UTF-8API 19开始但这个默认值在Java服务端、不同操作系统之间可能不一致。如果两端一个用UTF-8一个用GBK去加密解密结果就是密文能解出来但明文全是乱码。所有加密相关的代码一律显式指定StandardCharsets.UTF_8不要在字符集上依赖任何默认值。4.4 不要自己改代码逻辑实现“伪随机IV”有人为了省事会用UUID的前12个字节当IV或者用固定IV的递增序列做变形。SecureRandom的随机性来自系统的熵池而UUID的随机性版本也能达到密码学安全级别但问题是它的位数、格式和GCM对IV的字节序要求不一定兼容而且截断使用会降低熵。最稳妥的做法就是用SecureRandom直接生成12字节的随机IV不要发明创造。5. 从Blowfish迁移到AES-GCM过程中的实测验证5.1 单元测试怎么写才能保证迁移不出错算法替换这类重构没有一套完整的单元测试兜底上线就是在赌运气。我建议至少准备三类测试用例第一类是固定向量测试。加密一个固定字符串用已知的密钥和固定的IV断言输出的密文Base64串是确定的。这里注意由于生产代码里IV每次都是随机生成的固定向量测试只能通过一个单独暴露的、接受外部传入IV的方法来做生产入口仍然走随机IV。这类测试的目的是验证Cipher配置没有在重构中发生变化。第二类是加解密往返测试。随机生成不同长度的明文包括空字符串、单字符、几百字节的普通文本、包含中文和表情符号的Unicode文本加密后立即解密断言明文一致。这个测试能暴露编码、填充、截断处理的问题。第三类是篡改检测测试。加密一段文本后手动修改密文中的任意一个字节再执行解密断言抛出AEADBadTagException。这个测试直接验证GCM的完整性校验特性有没有真正生效。5.2 性能与容量预估Blowfish和AES-GCM实际表现在Android设备上AES-GCM因为有硬件AES指令集加速ARMv8以上普遍支持性能表现通常优于Blowfish。我用一台中端测试机骁龙7系处理器分别对1KB和1MB的数据做了加密测试结果大致是AES-GCM处理1KB数据的耗时在0.1毫秒级别处理1MB数据在3-5毫秒Blowfish的耗时大约是AES-GCM的1.2到1.5倍但绝对值都很小。也就是说对移动端常规的字段级别加密来说性能差异根本感知不到真正影响体验的是网络传输和数据库I/O而不是加密本身。不过有一个场景需要特别提醒如果你拿加密来存储大量数据比如整个数据库文件加密那么AES-GCM的IV管理和密文格式设计就要更复杂。数据库文件可能超过GB级别GCM模式虽然支持大数据量加密但在Android上一次性把整个文件读进内存再加密显然不现实这时候更适合用流式加密方案或者考虑SQLCipher这类已经封装好完整方案的工具库。5.3 我实际项目中踩过的一个坑Provider差异导致的不兼容有一次用一台老的三星设备做回归测试发现加密在部分机型上直接抛出NoSuchAlgorithmException看堆栈是Cipher.getInstance(AES/GCM/NoPadding)初始化失败。查了半天发现问题出在GCM这个模式在某个Android版本的Conscrypt Provider里支持不完整系统的默认Provider列表里没有注册GCM的算法。解决方案是显式声明Provider或者把Provider列表切换顺序。调试这种问题时的临时解决办法是这样但不建议生产环境这么写只在排查阶段用来确认归因Cipher cipher Cipher.getInstance(AES/GCM/NoPadding, Conscrypt);生产级的解决思路是把minSdkVersion提到21以上AES-GCM在API 21官方支持并且在真机测试时覆盖不同厂商的ROM。这种Provider差异问题在国产ROM高发因为个别厂商会裁剪或替换系统的加密Provider实现。5.4 用代码审查清单跑一遍比任何文档都管用迁移完成后的代码审查我习惯按一个固定的清单过一遍确认没有直接使用ECB模式所有对称加密都用了CBC或GCM实际项目里建议只用GCM。确认IV长度正确GCM模式下是12字节CBC模式下是16字节等于分组长度。确认Cipher.getInstance()的算法字符串里显式写了模式和填充没有只写算法名。确认加解密用的字符集显式指定为UTF-8。确认密钥没有出现在代码仓库、SharedPreferences或者明文日志里。确认解密时对密文的异常处理是安全的比如不要把BadPaddingException的详细堆栈直接返回给客户端。确认错误日志里不包含完整密文和密钥只保留必要的错误码。6. 写在最后对“Blowfish安全吗”这个问题的一个直接回答回到标题那个问题“Blowfish现在哪种加密算法安全”。我的回答是Blowfish本身没有在学术层面被完全攻破但它的64位分组限制了它在现代安全体系中的地位而AES-GCM和ChaCha20-Poly1305已经在安全性、性能、标准化程度、Android生态适配度上全面超越它。如果你是在维护老项目不要急躁着一次性把所有数据重加密用一个带版本标识的双读阶段平滑过渡比一次性全量迁移稳妥得多。在Android上做编码实践优先级如果排个序应该是算法选型正确AES-GCM 密钥管理安全Keystore 传输层保护TLS/HTTPS 日志和异常处理规范。我个人做这类迁移的经验是先把解密路径保住再加新路径最后再清理老代码每一步都要有测试用例兜底。Blowfish这个算法在未来或许只出现在密码学教材里但它在Android项目里留下的经验——关于密钥管理、关于模式选择、关于兼容性测试的那些教训——比算法本身更值得被记住。
返回列表