安全架构提供了蓝图,但实现决定了你的移动应用程序是否真正保护用户数据。如果代码包含漏洞、使用弱加密或错误处理敏感数据,即使是完美设计的安全模型也会失败。安全理论与安全代码之间的差距正是大多数数据泄露发生的地方。
移动平台提供了强大的安全 API,但正确使用它们需要理解其细微差别。iOS Keychain 和 Android Keystore 提供硬件支持的加密,但配置错误的可访问性设置可能会暴露数据。证书固定加强了网络安全,但不当的实现会在证书轮换期间破坏应用程序。Root 检测可以识别受损设备,但简单的检查很容易被绕过。
本文重点关注实现——保护移动应用程序的实际代码。我们将研究安全存储模式、网络安全实现、代码混淆技术、运行时保护机制和身份验证流程。每个部分都提供了可以调整的工作代码,以及将安全功能变成漏洞的陷阱。
安全基础:代码中永远不应该出现的内容
在实现安全功能之前,要了解什么永远不应该出现在你的代码库中。这些错误很常见、容易被利用,而且完全可以避免。
硬编码密钥:最大的罪过
在源代码中硬编码敏感数据是最常见也是最危险的安全错误。一旦提交到版本控制,密钥就会永远保留在仓库历史中——即使在删除之后。
🚫 永远不要在代码中硬编码这些内容
凭证和密钥
- 密码和口令
- API 密钥和密钥
- 私钥和证书
- 数据库凭证
- OAuth 客户端密钥
- 加密密钥
敏感配置
- 嵌入凭证的生产服务器 URL
- 第三方服务令牌
- 签名密钥
- Webhook 密钥
- 服务账户凭证
个人信息
- 测试代码中的用户数据
- 电子邮件地址
- 电话号码
- 任何用于测试的 PII
为什么硬编码密钥很危险
源代码不是安全存储。开发人员共享仓库,CI/CD 系统访问代码,反编译器从二进制文件中提取字符串,版本控制无限期地保留历史记录。
// ❌ 永远不要这样做
class ApiClient {
companion object {
private const val API_KEY = "sk_live_51H7xK2eZvKYlo2C..." // 暴露了!
private const val SECRET = "whsec_abc123..." // 在版本控制中!
private const val DB_PASSWORD = "MyP@ssw0rd123" // 所有开发人员都能看到!
}
}
// ❌ 永远不要这样做
class Configuration {
static let apiKey = "AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY" // 暴露了!
static let privateKey = "-----BEGIN PRIVATE KEY-----\nMIIE..." // 灾难!
}
任何有仓库访问权限的人都能看到这些密钥。反编译应用程序会暴露它们。攻击者在 GitHub 上搜索暴露的密钥。一旦泄露,必须立即轮换密钥——假设你发现了泄露。
正确的方法:基于环境的配置
密钥属于安全存储,在运行时加载,永远不要提交到版本控制。
// ✅ 从安全存储加载
class ApiClient(context: Context) {
private val secureStorage = SecureStorage(context)
fun getApiKey(): String? {
// 从加密存储加载,而不是硬编码
return secureStorage.loadToken("api_key")
}
}
// ✅ 从 Keychain 加载
class Configuration {
static func getApiKey() -> String? {
return SecureStorage.loadToken(forKey: "api_key")
}
}
配置管理策略
不同类型的配置需要不同的方法:
🔧 配置最佳实践
公共配置(可以安全提交)
- 功能标志
- UI 配置
- 非敏感 URL
- 超时值
- 缓存大小
特定环境(构建时注入)
- 服务器端点(不含凭证)
- 环境标识符
- 调试标志
- 分析 ID(非敏感)
密钥(仅运行时加载)
- API 密钥和令牌
- 加密密钥
- 凭证
- 私钥
- 服务密钥
构建时密钥注入
对于构建时需要的密钥,从环境变量或安全构建系统注入它们——永远不要提交它们。
// Android: build.gradle
android {
defaultConfig {
// 从环境加载,而不是硬编码
buildConfigField "String", "API_KEY", "\"${System.getenv('API_KEY') ?: ''}\""
}
}
# iOS: 使用 xcconfig 文件(不提交)
# Config.xcconfig
API_KEY = ${API_KEY}
# .gitignore
Config.xcconfig
提供显示结构但不包含实际密钥的模板文件:
// Config.template.xcconfig(已提交)
// 复制到 Config.xcconfig 并填写实际值
API_KEY = YOUR_API_KEY_HERE
记录敏感数据:无声的暴露
记录敏感数据会将其暴露给任何有日志访问权限的人——开发人员、支持人员、崩溃报告服务以及获得设备访问权限的攻击者。
// ❌ 永远不要这样做
fun login(username: String, password: String) {
Log.d("Auth", "Login attempt: $username / $password") // 在日志中暴露!
// ...
}
fun processPayment(cardNumber: String, cvv: String) {
Log.d("Payment", "Processing card: $cardNumber, CVV: $cvv") // 违反 PCI!
// ...
}
// ❌ 永远不要这样做
func authenticate(token: String) {
print("Auth token: \(token)") // 在控制台中可见!
// ...
}
日志保留在系统日志、崩溃报告和分析平台中。从生产构建中删除敏感日志记录:
# ProGuard: 在发布版本中删除日志记录
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
版本控制卫生
一旦提交,密钥就会保留在仓库历史中。预防至关重要:
⚠️ 版本控制安全
提交前
- 检查差异中的密钥
- 使用预提交钩子扫描密钥
- 维护配置文件的 .gitignore
- 使用密钥扫描工具
如果密钥已提交
- 立即轮换受损密钥
- 不要只是删除——历史记录会保留它们
- 使用 BFG Repo-Cleaner 等工具清除历史记录
- 通知安全团队
预防工具
- git-secrets (AWS)
- detect-secrets (Yelp)
- GitHub 密钥扫描
- GitGuardian
移动应用中的 API 密钥:特殊考虑
移动应用分发给可以提取任何嵌入数据的用户。即使是混淆或加密的密钥也可以被有决心的攻击者提取。
🔑 移动端 API 密钥策略
客户端 API 密钥
- 假设它们会被提取
- 使用权限最小的密钥
- 在服务器端实现速率限制
- 监控滥用
- 定期轮换
服务器端代理模式
- 将敏感密钥保留在服务器上
- 移动应用调用你的 API
- 你的服务器调用第三方 API
- 验证移动请求
- 在服务器端控制访问
当客户端密钥必要时
- 使用平台特定的限制(iOS bundle ID、Android 包名)
- 实现证书固定
- 添加请求签名
- 监控使用模式
- 准备好轮换程序
服务器端代理模式总是比在移动应用中嵌入密钥更安全,即使使用混淆也是如此。
安全存储实现
平台提供的安全存储机制是你的第一道防线。当 iOS Keychain 或 Android Keystore 可用时,永远不要实现自定义加密。
何时使用安全存储
安全存储用于运行时密钥——通过身份验证或 API 调用在应用安装后获得的数据。永远不要使用它来隐藏不应该在应用中的硬编码密钥。
✅ 安全存储的适当用途
运行时密钥
- 登录后收到的身份验证令牌
- 来自服务器的会话密钥
- 用户凭证(如果绝对必要)
- 临时加密密钥
- OAuth 令牌
不用于构建时密钥
- 不要在 Keychain 中存储硬编码的 API 密钥
- 不要加密硬编码的密钥并存储它们
- 不要使用安全存储来隐藏不应该存在的内容
- 服务器端密钥永远不应该到达客户端
iOS Keychain:完整实现
import Security
class SecureStorage {
static func saveToken(_ token: String, forKey key: String) -> Bool {
guard let data = token.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func loadToken(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
return nil
}
return token
}
static func deleteToken(forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
}
🔑 Keychain 可访问性级别
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
- 对敏感数据最安全
- 不备份到 iCloud
- 仅在设备解锁时可访问
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- 用于后台任务
- 首次解锁后可用
- 不备份
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
- 需要设备密码
- 如果删除密码则删除
- 最高安全性
Android 安全存储:EncryptedSharedPreferences
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class SecureStorage(private val context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
encryptedPrefs.edit().putString("auth_token", token).apply()
}
fun loadToken(): String? {
return encryptedPrefs.getString("auth_token", null)
}
fun deleteToken() {
encryptedPrefs.edit().remove("auth_token").apply()
}
}
Android Keystore 用于加密密钥
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class KeystoreManager {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
fun generateKey(alias: String): SecretKey {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val keySpec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}
fun getKey(alias: String): SecretKey? {
return keyStore.getKey(alias, null) as? SecretKey
}
}
⚠️ 存储反模式
永远不要这样做
- 在 UserDefaults/SharedPreferences 中存储密码
- 在代码中硬编码加密密钥
- 使用弱算法(DES、MD5、SHA1)
- 将敏感数据记录到控制台
- 在版本控制中存储 API 密钥
网络安全实现
仅使用 HTTPS 是不够的。适当的 TLS 配置、证书验证和请求签名提供了纵深防御。
iOS TLS 配置与证书验证
class SecureNetworking {
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.tlsMinimumSupportedProtocolVersion = .TLSv12
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func makeRequest(url: URL, completion: @escaping (Data?, Error?) -> Void) {
let task = session.dataTask(with: url) { data, response, error in
completion(data, error)
}
task.resume()
}
}
extension SecureNetworking: URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
var secResult = SecTrustResultType.invalid
let status = SecTrustEvaluate(serverTrust, &secResult)
if status == errSecSuccess &&
(secResult == .unspecified || secResult == .proceed) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
Android 网络安全配置
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
<application
android:networkSecurityConfig="@xml/network_security_config">
</application>
请求签名实现
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
class RequestSigner(private val secretKey: String) {
fun signRequest(method: String, path: String, body: String, timestamp: Long): String {
val payload = "$method\n$path\n$body\n$timestamp"
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
mac.init(keySpec)
val signature = mac.doFinal(payload.toByteArray())
return Base64.getEncoder().encodeToString(signature)
}
fun createHeaders(method: String, path: String, body: String): Map<String, String> {
val timestamp = System.currentTimeMillis()
val signature = signRequest(method, path, body, timestamp)
return mapOf(
"X-Timestamp" to timestamp.toString(),
"X-Signature" to signature
)
}
}
服务器端验证:
import hmac
import hashlib
import time
import base64
def verify_signature(method, path, body, timestamp, signature, secret_key):
current_time = int(time.time() * 1000)
if abs(current_time - int(timestamp)) > 300000: # 5 分钟
return False
payload = f"{method}\n{path}\n{body}\n{timestamp}"
expected_signature = hmac.new(
secret_key.encode(),
payload.encode(),
hashlib.sha256
).digest()
return hmac.compare_digest(
expected_signature,
base64.b64decode(signature)
)
🚫 网络安全错误
永远不要禁用证书验证
- 不要在生产环境中信任所有证书
- 不要忽略 SSL 错误
- 不要允许明文 HTTP 流量
强制使用强 TLS
- 最低 TLS 1.2
- 避免弱密码套件
- 使用平台安全配置
代码混淆实现
混淆提高了逆向工程的门槛,但并非万无一失。与服务器端验证结合使用。
Android ProGuard 配置
// app/build.gradle
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
# proguard-rules.pro
-keep class com.example.app.Application { *; }
-repackageclasses ''
-allowaccessmodification
# 删除日志记录
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
-keepclasseswithmembernames class * {
native <methods>;
}
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
字符串混淆
object StringObfuscator {
private const val KEY = 0x5A
fun obfuscate(input: String): ByteArray {
return input.toByteArray().map { (it.toInt() xor KEY).toByte() }.toByteArray()
}
fun deobfuscate(input: ByteArray): String {
return input.map { (it.toInt() xor KEY).toByte() }.toByteArray().toString(Charsets.UTF_8)
}
}
class ApiConfig {
companion object {
private val API_KEY_OBFUSCATED = byteArrayOf(
0x3e, 0x2f, 0x3a, 0x2b, 0x3c, 0x2d, 0x3e, 0x2f
)
fun getApiKey(): String {
return StringObfuscator.deobfuscate(API_KEY_OBFUSCATED)
}
}
}
🛡️ 混淆最佳实践
要混淆的内容
- 业务逻辑和算法
- API 端点和参数
- 内部类和方法名称
要保留的内容
- 公共 API 接口
- 基于反射的类
- 原生方法声明
- 序列化类
测试
- 彻底测试发布构建
- 验证崩溃报告可读
- 使用映射文件进行反混淆
UI 安全实现
通过截图和应用切换器预览保护敏感数据免受视觉捕获。
Android 屏幕捕获防护
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
class SecureActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 防止截图和屏幕录制
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
setContentView(R.layout.activity_secure)
}
}
iOS 截图检测
import UIKit
class SecureViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 检测何时截图
NotificationCenter.default.addObserver(
self,
selector: #selector(screenshotTaken),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
}
@objc private func screenshotTaken() {
// 记录事件或警告用户
print("检测到截图")
// 也可以暂时模糊敏感内容
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
应用切换器保护 - iOS
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var blurView: UIVisualEffectView?
func applicationWillResignActive(_ application: UIApplication) {
// 在快照前隐藏敏感内容
addBlurEffect()
}
func applicationDidBecomeActive(_ application: UIApplication) {
// 应用返回时恢复内容
removeBlurEffect()
}
private func addBlurEffect() {
guard let window = window, blurView == nil else { return }
let blurEffect = UIBlurEffect(style: .light)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.frame = window.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(blurView)
self.blurView = blurView
}
private func removeBlurEffect() {
blurView?.removeFromSuperview()
blurView = nil
}
}
使用占位符视图的替代方法:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var coverView: UIView?
func applicationWillResignActive(_ application: UIApplication) {
addCoverView()
}
func applicationDidBecomeActive(_ application: UIApplication) {
removeCoverView()
}
private func addCoverView() {
guard let window = window, coverView == nil else { return }
let cover = UIView(frame: window.bounds)
cover.backgroundColor = .white
// 可选:添加应用 logo
let imageView = UIImageView(image: UIImage(named: "logo"))
imageView.contentMode = .scaleAspectFit
imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
imageView.center = cover.center
cover.addSubview(imageView)
window.addSubview(cover)
self.coverView = cover
}
private func removeCoverView() {
coverView?.removeFromSuperview()
coverView = nil
}
}
应用切换器保护 - Android
import android.app.Activity
import android.os.Bundle
import android.view.View
import android.widget.ImageView
class MainActivity : AppCompatActivity() {
private var coverView: View? = null
override fun onPause() {
super.onPause()
// 在应用切换器快照前隐藏内容
showCoverView()
}
override fun onResume() {
super.onResume()
// 应用返回时恢复内容
hideCoverView()
}
private fun showCoverView() {
if (coverView == null) {
coverView = layoutInflater.inflate(R.layout.cover_screen, null)
addContentView(
coverView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
coverView?.visibility = View.VISIBLE
}
private fun hideCoverView() {
coverView?.visibility = View.GONE
}
}
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_centerInParent="true"
android:src="@drawable/logo"
android:contentDescription="@string/app_logo" />
</RelativeLayout>
🛡️ UI 安全最佳实践
屏幕捕获防护
- 仅对敏感活动应用 FLAG_SECURE
- 不要在所有屏幕上防止捕获
- 考虑用户对合法截图的需求
- 使用屏幕录制应用进行测试
应用切换器保护
- 在 onPause/willResignActive 中立即应用遮罩
- 在 onResume/didBecomeActive 中移除遮罩
- 使用简单、快速加载的遮罩视图
- 测试快速应用切换场景
性能考虑
- 保持遮罩视图轻量级
- 避免复杂的布局或动画
- 缓存遮罩视图以供重用
- 在低端设备上测试
运行时保护实现
检测受损环境并做出适当响应。
iOS 越狱检测
class JailbreakDetector {
static func isJailbroken() -> Bool {
let jailbreakPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in jailbreakPaths {
if FileManager.default.fileExists(atPath: path) {
return true
}
}
let testPath = "/private/jailbreak_test.txt"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
// 无法在沙箱外写入
}
if let url = URL(string: "cydia://package/com.example.package"),
UIApplication.shared.canOpenURL(url) {
return true
}
return false
}
}
Android Root 检测
class RootDetector(private val context: Context) {
fun isRooted(): Boolean {
return checkBuildTags() ||
checkSuperuserApk() ||
checkSuBinary() ||
checkRootManagementApps()
}
private fun checkBuildTags(): Boolean {
val buildTags = android.os.Build.TAGS
return buildTags != null && buildTags.contains("test-keys")
}
private fun checkSuperuserApk(): Boolean {
return try {
context.packageManager.getPackageInfo("com.noshufou.android.su", 0)
true
} catch (e: Exception) {
false
}
}
private fun checkSuBinary(): Boolean {
val paths = arrayOf(
"/system/app/Superuser.apk",
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/system/sd/xbin/su",
"/system/bin/failsafe/su",
"/data/local/su"
)
return paths.any { File(it).exists() }
}
private fun checkRootManagementApps(): Boolean {
val packages = arrayOf(
"com.topjohnwu.magisk",
"eu.chainfire.supersu",
"com.koushikdutta.superuser"
)
return packages.any { packageName ->
try {
context.packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
}
}
调试器检测
// iOS
func isDebuggerAttached() -> Bool {
var info = kinfo_proc()
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.stride
let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
return (result == 0) && ((info.kp_proc.p_flag & P_TRACED) != 0)
}
// Android
fun isDebuggerConnected(): Boolean {
return Debug.isDebuggerConnected() || Debug.waitingForDebugger()
}
fun isDebuggable(context: Context): Boolean {
return (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
}
🔍 响应策略
优雅降级
- 禁用敏感功能
- 向用户显示警告
- 限制功能
静默监控
- 记录到分析
- 服务器端风险评分
- 模式检测
硬阻止
- 拒绝运行
- 仅限高安全性应用
- 向用户清楚解释
生物识别身份验证实现
// iOS Face ID / Touch ID
import LocalAuthentication
class BiometricAuth {
func authenticate(completion: @escaping (Bool, Error?) -> Void) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
completion(false, error)
return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "验证以访问您的账户"
) { success, error in
DispatchQueue.main.async {
completion(success, error)
}
}
}
}
// Android 生物识别
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
class BiometricAuth(private val activity: FragmentActivity) {
fun authenticate(
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
override fun onAuthenticationFailed() {
onError("身份验证失败")
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("生物识别身份验证")
.setSubtitle("验证以访问您的账户")
.setNegativeButtonText("取消")
.build()
biometricPrompt.authenticate(promptInfo)
}
}
一个真实的实现故事:测试拯救生产环境
在证书轮换期间,我们的测试团队报告了失败,但将其视为"证书固定太难测试"而忽略了。他们在 UAT 中禁用了固定,因为这使他们的工作流程变得复杂。在生产发布前两小时,我亲自进行了调查。
新证书使用通配符通用名称(*.example.com)而不是特定域(api.example.com)。我们的固定逻辑将确切的 CN 映射到公钥——通配符不匹配。如果部署,每个移动用户都会失去连接。
🚫 UAT 固定差距
他们为什么禁用固定
- "太难测试"
- UAT 中频繁的证书更改
- 自签名证书
- "我们会在生产监控中发现问题"
危险的后果
- UAT 没有验证任何关于固定的内容
- 所有固定问题都会出现在生产环境中
- "成功"测试带来的虚假信心
- 用户会遇到测试从未发现的失败
我在剩余一小时时叫停了发布。我们获得了具有正确 CN 格式的证书,进行了彻底测试,并在几天后成功部署。
✅ 经验教训
永远不要在 UAT 中禁用固定
- 如果生产环境有固定,UAT 也必须有
- "太难测试"意味着在生产环境中发现
- UAT 必须镜像生产行为
- 接受运营负担是必要的
永远不要忽视测试失败
- 调查每个失败的根本原因
- 不要假设"在生产环境中会正常工作"
- 证书固定失败是信号
为灵活性而设计
- CN 到固定的映射允许密钥轮换
- 可以在不更新应用的情况下更新密钥
- 平衡安全性与运营现实
启用固定测试的困难不是错误——它是你的早期警告系统。接受运营负担作为适当安全验证的代价。
实现检查清单
在将安全代码部署到生产环境之前:
✅ 部署前验证
安全存储
- 使用平台 API(Keychain/Keystore)
- 正确的可访问性设置
- 日志中没有敏感数据
- 配置了备份排除
网络安全
- 强制使用 TLS 1.2+
- 启用证书验证
- 不允许明文流量
- 实现请求签名
代码保护
- 为发布版本启用 ProGuard/R8
- 混淆敏感字符串
- 从发布构建中删除日志记录
- 归档映射文件
运行时保护
- 实现 Root/越狱检测
- 适当的响应策略
- 调试器检测就位
- 测试优雅降级
测试
- 在真实设备上测试
- 验证多个操作系统版本
- 测试失败场景
- 在所有环境中启用安全功能
结论
安全实现是理论与现实相遇的地方。iOS Keychain 和 Android Keystore 等平台提供的 API 提供了优于任何自定义实现的硬件支持加密。网络安全需要适当的 TLS 配置、证书验证和请求签名——仅使用 HTTPS 是不够的。代码混淆提高了逆向工程的门槛,但必须与服务器端验证结合使用。运行时保护检测受损环境,允许适当的响应。
安全设计与安全代码之间的差距是发生数据泄露的地方。配置错误的 Keychain 可访问性会暴露数据。在生产环境中禁用证书验证允许中间人攻击。弱混淆提供虚假信心。简单的 root 检测很容易被绕过。每个实现细节都很重要。
测试安全实现是不可协商的。在多个操作系统版本的真实设备上测试。测试失败场景——当 Keychain 访问失败、证书无效、设备被 root 时会发生什么?在理想条件下工作但在边缘情况下失败的安全性提供了虚假信心。永远不要因为"太难"而在测试环境中禁用安全功能——这种困难是你的早期警告系统。
证书轮换事件证明了适当测试的重要性。因为使测试复杂化而在 UAT 中禁用固定意味着第一次真正的测试将在生产环境中进行。只有维护一个启用固定的单独暂存环境才能发现问题。启用安全功能进行测试的运营负担远小于生产中断的成本。
平台安全 API 不断发展,发现漏洞,攻击技术不断进步。随时了解安全更新,监控你使用的库的公告,并定期审查实现。昨天的最佳实践可能是今天的漏洞。移动安全是一个持续的过程,而不是一次性的实现。
在部署安全代码之前,了解你要防御的威胁以及实现是否真正提供保护。并非每个应用都需要 root 检测或代码混淆。将安全投资与你的风险状况相匹配。在添加高级保护之前,专注于基础——安全存储、适当的 TLS、强身份验证。
设备可能在用户的口袋里,但安全责任仍然是你的。仔细实现,彻底测试,永远不要为了方便而走捷径损害安全性。