公司动态

Android便签应用密码锁功能实现:基于PBKDF2与AES-GCM的数据安全方案

📅 2026/7/15 10:44:12
Android便签应用密码锁功能实现:基于PBKDF2与AES-GCM的数据安全方案
1. 项目概述与需求拆解最近在折腾一个老项目——米UI风格的便签程序源码。这个源码本身功能挺简洁的就是基础的增删改查UI模仿了小米便签那种清爽的风格。但用着用着总觉得缺了点什么。对就是密码锁。现在谁还没点不想让别人随手翻看的私密笔记呢可能是临时的账号密码、一些私人的灵感记录或者就是单纯不想公开的日记。原生的“下滑进入私密空间”功能固然好但那属于系统级集成对于我们这种独立的第三方应用源码来说要实现笔记级别的隐私保护就得自己动手在应用层加一道“锁”。这个需求非常明确在不改变便签核心的CRUD创建、读取、更新、删除逻辑和米UI风格的前提下为单条或多条笔记增加密码保护功能。用户可以对任意笔记单独加密查看时必须输入正确密码。这不仅仅是前端弹个输入框那么简单它涉及到密码的存储绝不能明文、验证流程、加密笔记的存储与解密、以及整个UI交互逻辑的无缝融入。目标用户就是那些使用此源码进行二次开发的开发者或者对Android应用开发、数据安全感兴趣的爱好者他们需要一个清晰、安全、可落地的实现方案。2. 核心设计思路与架构选型给便签加密码听起来简单但设计上需要权衡安全、体验和开发复杂度。核心思路是前端交互触发加密后端本地进行安全的密码处理与数据加密加密后的内容存入数据库。2.1 密码处理与存储方案这是安全的核心绝不能将用户密码明文存储甚至不能存储密码本身。标准的做法是使用单向哈希算法。为什么是哈希而不是加密加密如AES是可逆的有密钥就能解密。但我们不需要知道用户的原始密码只需要验证用户输入的密码是否正确。哈希算法如SHA-256, bcrypt, PBKDF2是单向的将密码转换成固定长度的字符串哈希值且几乎无法反向推导出原密码。我们只存储这个哈希值。选择哪种哈希算法对于移动端或桌面端应用bcrypt或PBKDF2是更佳选择。它们内部包含了加盐Salt和多次迭代的过程能有效抵御彩虹表攻击。即使两个用户密码相同由于随机盐值不同最终的哈希值也完全不同。加盐Salt一个随机生成的字符串与密码拼接后再进行哈希防止攻击者使用预计算的哈希表彩虹表进行破解。迭代次数重复哈希过程的次数增加计算成本使暴力破解变得极其缓慢。实操设计当用户首次为某条笔记设置密码时系统在后台生成一个随机盐值结合用户输入的密码使用PBKDF2WithHmacSHA256算法进行多次例如10000次哈希运算生成一个密码哈希值。我们将这个哈希值和盐值一起与这条笔记的唯一ID关联存储例如在笔记数据表中新增password_hash和salt字段。原始密码在任何时候都不被存储。2.2 笔记内容加密方案密码验证通过后笔记内容本身也需要保护。这里应该使用对称加密算法因为我们需要在验证密码后解密出原文进行查看和编辑。密钥从哪里来我们不能直接用用户密码作为加密密钥。更安全的做法是在用户设置密码时使用该密码派生出一个专门的加密密钥。可以使用PBKDF2算法以用户密码和另一个随机数或笔记ID作为输入生成一个固定长度如256位的密钥专门用于加密该条笔记的内容。选择哪种对称加密算法AESAdvanced Encryption Standard是当前公认安全且高效的标准。我们选择AES-256-GCM模式。GCM模式不仅提供了加密功能还提供了认证Authentication可以防止密文被篡改。加密什么加密的对象应该是笔记的核心内容标题和正文而不是整个笔记对象因为创建时间、ID等元数据可能无需加密。加密后将得到的密文、初始化向量IV和认证标签GCM Tag一起存储到数据库的新字段如encrypted_content中。同时将原内容字段清空或标记。流程简述设置密码/加密用户输入密码 - 系统用密码派生出加密密钥 - 用该密钥加密笔记内容 - 存储密文、IV、Tag同时用密码生成密码哈希和盐值存储。查看笔记/解密用户输入密码 - 系统用输入密码和存储的盐值计算哈希 - 与存储的密码哈希比对 - 验证通过后用输入密码派生出加密密钥 - 用该密钥、IV、Tag解密encrypted_content字段 - 得到明文并显示。2.3 数据库与前端交互调整数据库表调整 需要在便签数据表中至少新增以下几个字段is_encrypted(BOOLEAN): 标记该笔记是否已加密。password_hash(TEXT): 存储密码的哈希值。salt(TEXT): 存储用于密码哈希的盐值。encrypted_content(TEXT): 存储加密后的笔记内容JSON字符串可包含标题、正文的密文。encryption_iv(TEXT): 存储AES加密使用的初始化向量。auth_tag(TEXT): 存储GCM模式的认证标签可选但推荐。前端交互流程在笔记列表页已加密的笔记需要有一个明显的视觉标识如一把锁的图标。点击已加密的笔记条目不应直接进入详情页而应弹出一个密码输入对话框。密码验证通过后再跳转到详情页并加载解密后的内容。在笔记编辑页面需要提供“设置密码”、“修改密码”、“移除密码”的入口。设置或修改密码时需要用户输入两次以确认。3. 关键技术点实现详解下面我们深入到代码层面看看如何实现上述设计。这里以Android平台Kotlin/Java为例结合SQLite数据库。3.1 密码哈希与验证实现我们将创建一个PasswordUtils工具类。import javax.crypto.SecretKeyFactory import javax.crypto.spec.PBEKeySpec import java.security.SecureRandom import java.util.Base64 object PasswordUtils { private const val ITERATIONS 10000 private const val KEY_LENGTH 256 private const val SALT_LENGTH 32 private const val HASH_ALGORITHM PBKDF2WithHmacSHA256 /** * 生成一个随机的盐值 */ fun generateSalt(): String { val random SecureRandom() val salt ByteArray(SALT_LENGTH) random.nextBytes(salt) return Base64.getEncoder().encodeToString(salt) } /** * 根据密码和盐值生成哈希 * param password 用户输入的明文密码 * param salt Base64编码的盐值 * return Base64编码的哈希值 */ fun hashPassword(password: String, salt: String): String { val spec PBEKeySpec( password.toCharArray(), Base64.getDecoder().decode(salt), ITERATIONS, KEY_LENGTH ) val factory SecretKeyFactory.getInstance(HASH_ALGORITHM) val hash factory.generateSecret(spec).encoded return Base64.getEncoder().encodeToString(hash) } /** * 验证密码 * param inputPassword 用户尝试输入的密码 * param storedHash 数据库中存储的哈希值 * param storedSalt 数据库中存储的盐值 * return 验证是否通过 */ fun verifyPassword(inputPassword: String, storedHash: String, storedSalt: String): Boolean { val calculatedHash hashPassword(inputPassword, storedSalt) // 使用恒定时间比较防止时序攻击 return MessageDigest.isEqual( Base64.getDecoder().decode(calculatedHash), Base64.getDecoder().decode(storedHash) ) } }注意MessageDigest.isEqual是Java提供的用于安全比较字节数组的方法可以防止基于比较时间的侧信道攻击。在Android中可以直接使用。3.2 笔记内容的AES加密解密实现接着我们创建EncryptionUtils工具类来处理笔记内容的加密和解密。import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import java.security.SecureRandom import java.util.Base64 object EncryptionUtils { private const val AES_KEY_LENGTH 256 // bits private const val GCM_TAG_LENGTH 128 // bits private const val GCM_IV_LENGTH 12 // bytes, 推荐值 /** * 从密码派生加密密钥 (Key Derivation) * 这里为了简化使用PBKDF2从密码派生。实践中这个派生过程应该与密码哈希分开使用不同的盐。 * param password 用户密码 * param salt 用于派生密钥的盐可以使用笔记ID或另一个随机值 * return AES密钥 */ fun deriveKeyFromPassword(password: String, salt: ByteArray): SecretKey { // 注意这里的迭代次数和算法可以与密码哈希不同 val spec PBEKeySpec( password.toCharArray(), salt, 10000, // 迭代次数 AES_KEY_LENGTH ) val factory SecretKeyFactory.getInstance(PBKDF2WithHmacSHA256) val keyBytes factory.generateSecret(spec).encoded return SecretKeySpec(keyBytes, AES) } /** * 加密笔记内容 * param content 明文笔记内容可以是JSON字符串 * param key 加密密钥 * return Triple(密文Base64, IV Base64, Tag Base64) */ fun encryptContent(content: String, key: SecretKey): TripleString, String, String { val cipher Cipher.getInstance(AES/GCM/NoPadding) val iv ByteArray(GCM_IV_LENGTH) SecureRandom().nextBytes(iv) // 生成随机IV val gcmSpec GCMParameterSpec(GCM_TAG_LENGTH, iv) cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec) val encryptedBytes cipher.doFinal(content.toByteArray(Charsets.UTF_8)) // GCM模式下doFinal返回的字节数组包含密文和认证标签 val tagLength GCM_TAG_LENGTH / 8 val cipherText encryptedBytes.copyOfRange(0, encryptedBytes.size - tagLength) val authTag encryptedBytes.copyOfRange(encryptedBytes.size - tagLength, encryptedBytes.size) return Triple( Base64.getEncoder().encodeToString(cipherText), Base64.getEncoder().encodeToString(iv), Base64.getEncoder().encodeToString(authTag) ) } /** * 解密笔记内容 * param encryptedTextBase64 密文Base64 * param ivBase64 IV Base64 * param tagBase64 认证标签Base64 * param key 加密密钥 * return 解密后的明文字符串 */ fun decryptContent( encryptedTextBase64: String, ivBase64: String, tagBase64: String, key: SecretKey ): String { val cipher Cipher.getInstance(AES/GCM/NoPadding) val iv Base64.getDecoder().decode(ivBase64) val encryptedBytes Base64.getDecoder().decode(encryptedTextBase64) val authTag Base64.getDecoder().decode(tagBase64) // 将密文和认证标签组合 val combinedBytes encryptedBytes authTag val gcmSpec GCMParameterSpec(GCM_TAG_LENGTH, iv) cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) val decryptedBytes cipher.doFinal(combinedBytes) return String(decryptedBytes, Charsets.UTF_8) } }3.3 数据库层与业务逻辑整合我们需要更新笔记的实体类Note和数据库访问对象NoteDao。实体类更新 (Note.kt):data class Note( val id: Long? null, var title: String, var content: String, val createdAt: Long, var updatedAt: Long, // 新增字段 var isEncrypted: Boolean false, var passwordHash: String? null, // 密码哈希 var salt: String? null, // 密码哈希盐 var encryptedData: String? null, // 存储加密后的标题和内容的JSON或直接存储加密文本 var encryptionIv: String? null, // 加密IV var authTag: String? null // GCM认证标签 )核心业务逻辑设置密码与加密笔记当用户为一条笔记设置密码时需要执行一系列原子操作生成密码盐计算密码哈希。生成一个随机的“密钥派生盐”可以用笔记ID或新生成结合用户密码派生出加密密钥。将笔记的title和content组合如转为JSON后用加密密钥进行加密。将原title和content清空或置为默认值如“已加密的笔记”将加密得到的密文、IV、Tag存入新字段。将isEncrypted设为true并存储passwordHash和salt。将所有更新一次性提交到数据库。这个过程必须在一个事务中完成避免出现密码设置了但内容未加密或者反之的不一致状态。核心业务逻辑查看与解密笔记用户点击已加密笔记弹出密码输入框。输入密码后用输入的密码和数据库中存储的salt计算哈希与存储的passwordHash比对。如果密码正确再用输入的密码和笔记ID或存储的某个固定值派生出加密密钥。使用该密钥结合数据库中存储的encryptionIv、authTag解密encryptedData字段。将解密出的JSON解析还原title和content并显示给用户。注意解密后的内容应仅保存在内存中用于本次显示不应写回数据库的原始字段。4. 前端UI/UX适配与米UI风格融合功能实现了体验更要做好。我们需要将密码功能无缝融入原有的米UI风格中。4.1 列表页与详情页的视觉区分在笔记列表的适配器Adapter中根据isEncrypted字段显示不同的图标和提示。!-- item_note.xml 列表项布局 -- LinearLayout ... ImageView android:idid/icon_lock android:layout_width24dp android:layout_height24dp android:srcdrawable/ic_lock_outline !-- 解锁图标 -- android:visibility{note.encrypted ? View.VISIBLE : View.GONE} / TextView android:idid/text_title android:text{note.encrypted ? ‘已加密的笔记‘ : note.title} / !-- 如果已加密标题可以显示为固定文本 -- /LinearLayout点击已加密的笔记项时拦截点击事件弹出密码输入对话框而不是直接打开详情页。4.2 密码输入与验证对话框设计一个符合米UI设计语言的对话框。米UI通常使用圆角、干净的背景和明确的按钮。fun showPasswordDialog(noteId: Long, onSuccess: (decryptedTitle: String, decryptedContent: String) - Unit) { val dialog MaterialAlertDialogBuilder(context) .setTitle(请输入密码) .setView(R.layout.dialog_password_input) // 自定义布局包含一个EditText .setPositiveButton(确定) { dialog, which - val inputPassword passwordEditText.text.toString() // 在后台线程进行验证和解密 viewModelScope.launch(Dispatchers.IO) { val note noteRepository.getNoteById(noteId) if (PasswordUtils.verifyPassword(inputPassword, note.passwordHash, note.salt)) { // 密码正确派生密钥并解密内容 val keySalt note.id.toString().toByteArray() // 示例使用笔记ID作为密钥派生盐 val key EncryptionUtils.deriveKeyFromPassword(inputPassword, keySalt) val decryptedJson EncryptionUtils.decryptContent( note.encryptedData!!, note.encryptionIv!!, note.authTag!!, key ) // 解析JSON获取标题和内容 val (title, content) parseDecryptedJson(decryptedJson) // 切换到主线程回调 withContext(Dispatchers.Main) { onSuccess(title, content) dialog.dismiss() } } else { withContext(Dispatchers.Main) { Toast.makeText(context, 密码错误, Toast.LENGTH_SHORT).show() // 清空输入框 passwordEditText.text?.clear() } } } } .setNegativeButton(取消, null) .create() dialog.show() }4.3 编辑页面的密码管理入口在笔记编辑页面EditNoteActivity或Fragment的菜单栏或工具栏上添加一个锁形图标按钮。点击后弹出菜单提供“设置密码”、“修改密码”、“移除密码”选项。设置密码当前笔记未加密时显示。点击后弹出一个对话框要求输入两次新密码。确认后执行上述的加密流程。修改密码当前笔记已加密时显示。需要先验证旧密码通过后再输入两次新密码。这里有一个关键点修改密码意味着需要用新密码重新加密内容。流程是验证旧密码 - 用旧密码解密出原文 - 用新密码生成新哈希和盐 - 用新密码派生的新密钥重新加密原文 - 更新数据库中的所有相关字段passwordHash,salt,encryptedData等。移除密码当前笔记已加密时显示。需要验证当前密码。通过后用该密码解密出原文然后清除所有加密相关字段isEncryptedfalse,passwordHashnull,encryptedDatanull等并将解密出的原文写回title和content字段。5. 安全增强与边界情况处理实现基础功能后我们必须考虑一些安全细节和边界情况让这个功能更健壮。5.1 防止暴力破解与安全策略密码尝试次数限制在本地实现一个简单的计数器。当连续输错密码达到一定次数如5次时可以锁定该笔记一段时间如5分钟或者要求回答一个安全问题如果设置了的话。计数器可以存储在SharedPreferences中以笔记ID为键。密码强度提示在设置密码时可以实时检查密码强度长度、包含字符类型给予用户提示但不要强制要求过于复杂的规则以免影响用户体验。生物识别认证集成高级对于支持指纹或面部识别的设备可以在密码验证通过后询问用户是否启用生物识别快速解锁。下次进入时可以先尝试生物识别失败再回退到密码输入。注意生物识别验证通过后用于解密的密钥绝不能长期存储在Keystore或安全存储区之外最好是每次都用生物识别结果或由其解锁的Keystore中的密钥来解密一个主密钥再用主密钥解密笔记内容。5.2 数据备份与迁移的考量备份包含加密数据当用户备份数据库或导出笔记时加密的笔记会以密文形式被备份。这是安全的因为即使备份文件泄露没有密码也无法解密。恢复时的验证从备份恢复时加密笔记的passwordHash和salt也随之恢复。用户必须输入正确的原始密码才能解密。无法通过“忘记密码”来找回加密笔记的内容这是由哈希和加密的单向性保证的。必须在UI上明确提示用户。跨设备同步如果实现如果便签程序有云同步功能那么加密笔记的密文和密码哈希等字段需要同步到云端。解密过程仍然在本地设备进行密码不会上传。这要求所有设备的加密/解密算法和参数必须完全一致。5.3 性能与用户体验优化异步操作密码哈希、加密、解密都是计算密集型操作必须在后台线程如Dispatchers.IO执行避免阻塞主线程导致界面卡顿。内存安全解密后的明文内容在内存中应尽快使用。在Activity/Fragment销毁时确保保存这些内容的变量被及时清空置为null减少敏感信息在内存中的驻留时间。密钥的临时性派生出的加密密钥应在每次需要时临时计算用后即弃不要长期保存在内存中更不要序列化到本地文件。6. 常见问题排查与调试心得在实际开发和测试中你可能会遇到以下问题问题1密码验证总是失败即使输入了正确的密码。排查步骤检查盐值存储和传递确保在hashPassword和verifyPassword时使用的是完全相同的盐值字符串。打印或日志输出存储的salt和用于验证的salt看是否一致。常见错误是在存储或读取时发生了意外的编码转换。检查哈希算法和参数确保生成哈希和验证哈希时使用的算法HASH_ALGORITHM、迭代次数ITERATIONS、密钥长度KEY_LENGTH完全一致。一个字符的差异都会导致结果不同。密码字符串处理确保前端输入框获取的密码字符串没有多余的空格特别是首尾空格。可以使用.trim()处理。问题2解密时抛出BadPaddingException或其他加密相关异常。排查步骤检查密钥一致性加密和解密必须使用同一个密钥。确保派生密钥时使用的密码和盐密钥派生盐与加密时完全一致。密钥派生盐最好与笔记ID或某个固定存储值绑定。检查IV和Tag确保解密时传入的IV和认证标签Tag与加密时生成并存储的完全一致。GCM模式对IV和Tag极其敏感。检查数据完整性确保从数据库读取的encryptedData、encryptionIv、authTag字段没有损坏或截断。特别是在使用TEXT字段存储Base64字符串时要确保数据库编码能正确保存。算法/模式/填充字符串确保加密和解密时Cipher.getInstance(“AES/GCM/NoPadding”)中的字符串完全一致。不同提供商可能有细微差别。问题3已加密的笔记在列表页显示时标题或预览内容泄露。原因与解决在加密笔记时不仅加密了content也应该处理title。通常将title和content组合成一个JSON对象再加密。加密后将数据库中原title字段设置为一个无害的占位符如“已加密的笔记”。这样在列表页就不会泄露真实标题。解密后再从JSON中解析出原始标题。问题4修改密码后用旧密码竟然还能解密一次原因这通常是事务一致性问题。修改密码的步骤验证旧密码、解密、用新密码加密、更新数据库必须作为一个原子操作。如果在更新完passwordHash后程序崩溃或中断导致encryptedData未被新密钥重新加密那么旧密钥由旧密码派生仍然可以解密旧的encryptedData。解决方法是使用数据库事务确保所有更新密码哈希、盐、加密数据全部成功或全部失败。实操心得日志策略在开发阶段可以在安全的位置如仅Debug模式打印关键步骤的哈希值、密钥派生盐的前几位、IV等但绝对不要记录密码或解密后的明文。上线前务必移除这些日志。单元测试先行为PasswordUtils和EncryptionUtils编写详尽的单元测试模拟各种正常和异常情况空密码、错误密码、数据损坏等。这能极大降低集成调试的难度。循序渐进先在一个独立的环境里测试密码哈希和加密解密的核心函数确保它们工作正常。然后再集成到数据库操作中最后才处理UI交互。分阶段开发、测试和调试。