iOS Keychain Services:安全存储的基石

iOS Keychain Services:安全存储的基石

详细解释 iOS Keychain Services 的技术原理,以及 JSBox 中 $keychain API 如何封装这一重要服务。

理解 Keychain 不仅仅是知道它能存储密码,更要理解它为何安全,以及如何利用其特性编写更安全的 JSBox 脚本。


前言

在 iOS 应用开发中,数据安全始终是重中之重。当涉及到用户的敏感信息,如登录凭据、API Token、加密密钥、证书等时,简单的文件存储或偏好设置(如 UserDefaults)是远远不够的,因为它们可能以明文或易于逆向的方式暴露数据。这时,iOS 的钥匙串服务 (Keychain Services) 就成为了不二之选。

JSBox 通过其简洁的 $keychain API 封装了这一强大的原生服务,让开发者能够轻松地在脚本中实现安全的数据存储。

一、什么是 Keychain Services?

Keychain Services 是苹果在 macOS 和 iOS 平台上提供的一项安全存储服务。它是一个加密的数据库,用于存储小块敏感信息(通常称为“钥匙串项”),例如:

  1. 用户密码: 应用程序的登录凭据(用户名和密码)。
  2. 网络凭据: Wi-Fi 密码、VPN 证书、FTP 登录信息等。
  3. 加密密钥: 用于数据加密和解密的对称密钥或非对称密钥。
  4. 数字证书: SSL/TLS 证书、代码签名证书等。
  5. 不透明数据: 任何需要安全保护的小块二进制数据(例如,OAuth Token)。

这些信息被存储在一个**系统级(而非应用沙盒内)**的、高度加密的、受严格访问控制保护的数据库中。

二、Keychain 的核心技术原理:为何如此安全?

Keychain Services 的安全性源于其多层次的加密和访问控制机制:

A. 硬件支持的加密 (Hardware-Backed Encryption)

  • 设备 UID (Unique Device ID): 每个 iOS 设备都有一个独一无二的硬件 UID。这个 UID 在设备出厂时被烧录到处理器中,且无法更改或访问。
  • 用户密码 (Passcode/Biometrics): 用户在设备上设置的锁屏密码(Passcode)或生物识别信息(Touch ID/Face ID)。
  • 加密密钥派生: Keychain 中的数据不会直接用用户的密码加密。相反,系统会结合设备 UID 和用户的设备密码(或从生物识别中派生的密钥)来生成一个主密钥(master key)。这个主密钥用于加密和解密 Keychain 数据库。这意味着:
    • 即使 Keychain 数据库被恶意提取,没有设备的 UID 和用户的密码,也无法解密。
    • 没有硬件支持(Secure Enclave),仅仅通过软件也无法获取到生成密钥所需的核心组件。
  • Secure Enclave (安全隔区): 在支持的设备上,Keychain 服务的最高安全级别会利用 Secure Enclave。Secure Enclave 是一个独立的、隔离的协处理器,拥有自己的安全启动流程、加密存储和随机数生成器。用于生成和存储密钥,并且密钥永远不会离开 Secure Enclave。这意味着,即使 iOS 主处理器被攻破,攻击者也无法直接从 Secure Enclave 中提取加密密钥,大大增强了防破解能力。

B. 强大的访问控制 (Access Control Lists - ACLs)

Keychain Item 存储的不仅仅是数据本身,还有一系列属性 (Attributes),这些属性定义了该数据的类型、创建者、所有者、以及最重要的——谁可以在什么条件下访问它

  1. Item Class (项目类型): 每个钥匙串项都有一个类型,如:
    • kSecClassGenericPassword (通用密码):最常用,用于存储自定义的用户名/密码对。
    • kSecClassInternetPassword (互联网密码):用于存储网站登录凭据。
    • kSecClassCertificate (证书):存储数字证书。
    • kSecClassKey (加密密钥):存储加密/解密密钥。
  2. Service / Account (服务/账户):
    • kSecAttrService (服务):用于标识存储数据的来源或应用程序/服务(例如,“我的邮件服务”,“你的应用名”)。
    • kSecAttrAccount (账户):用于标识服务的特定用户账户(例如,用户的登录名)。
    • 这两个属性通常用于唯一标识一个钥匙串项,使得多个应用可以存储同一个服务的不同账户信息,或同一个应用存储多个服务的账户信息。
  3. Accessibility (可访问性): 这是 Keychain 安全性的关键配置之一。它定义了钥匙串项何时可以被访问:
    • kSecAttrAccessibleAfterFirstUnlock (第一次解锁后):一旦设备被用户解锁过一次,该项就可以一直被访问,直到设备重启。重启后,需要用户再次解锁才能访问。这是最常用的选项,提供了很好的安全性和便利性平衡。
    • kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:同上,但不会同步到 iCloud Keychain
    • kSecAttrAccessibleWhenUnlocked (解锁时):只有当设备处于解锁状态时才能访问。设备锁定时(即使屏幕亮着),也无法访问。安全性更高,但便利性稍差。
    • kSecAttrAccessibleWhenUnlockedThisDeviceOnly:同上,且不会同步到 iCloud Keychain
    • kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly (设置密码时):只要设备设置了密码,就可以访问。安全性最低,不推荐用于敏感数据。
    • kSecAttrAccessibleAlways (始终):无论设备是否解锁,始终可以访问。安全性最低,强烈不推荐。
    • kSecAttrAccessibleWhenPasscodeSet (或 kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly):需要设备设置了密码,可以在设备锁定时访问。不推荐用于非常敏感的数据。
  4. Access Control (访问控制): (iOS 9+ / Face ID / Touch ID)
    • kSecAttrAccessControl 属性允许你为钥匙串项添加额外的生物识别认证要求。
    • 例如,你可以配置一个钥匙串项,要求用户在每次访问时都进行 Face ID 或 Touch ID 验证,进一步提升安全性。

C. iCloud Keychain 同步

  • 功能: Keychain Services 支持将钥匙串项安全地同步到用户的 iCloud 账户。如果用户在多个设备上开启了 iCloud Keychain,那么存储在某个设备上的密码会自动同步到其所有其他设备上。
  • 原理: 同步过程是端到端加密的。数据在离开设备前就被加密,并在云端保持加密状态,只有用户自己的设备才能解密。
  • JSBox 相关性: 带有 ThisDeviceOnly 后缀的 Accessibility 选项会阻止同步,其他选项则会允许同步。

三、Keychain 与其他存储方式的对比

理解 Keychain 的优势,需要将其与 iOS 中其他的持久化方式进行对比:

  1. UserDefaults
    • UserDefaults 存储的是明文或易于反编译的属性列表文件。它适合存储用户偏好设置、非敏感配置等。
    • Keychain 存储的是加密数据,专门用于敏感信息。
  2. 与沙盒文件:
    • 存储在应用沙盒中的文件(如 $file 存储的文件)默认会受文件系统加密保护(Data Protection),但只要设备处于解锁状态,应用就可以直接访问这些文件。
    • Keychain 的加密层级更高,且可以与用户密码或生物识别绑定,即使设备解锁也可能无法访问。
    • 文件存储是应用私有的;Keychain 是系统级的,支持跨应用和 iCloud 同步(如果应用获得了相应权限,如 App Group)。
  3. 与数据库 (SQLite/Core Data):
    • SQLite 或 Core Data 用于存储结构化数据和大量数据,它们主要关注数据管理和查询性能。
    • 它们自身不提供像 Keychain 那样强大的硬件级加密和访问控制。如果要在数据库中存储敏感数据,通常需要开发者自行进行加密,并妥善管理加密密钥(而密钥本身可能需要存储在 Keychain 中)。
    • Keychain 适合存储小量、高敏感度、需安全认证的数据。

四、JSBox 中 $keychain 的封装与使用

JSBox 的 $keychain API 将原生 Keychain Services 的复杂操作(如构建查询字典、处理各种状态码)进行了高度抽象和简化,让你能够以 JavaScript 的方式便捷地进行安全存储。

A. $keychain 方法解析

  1. $keychain.set(key, value, domain):存储钥匙串项

    • key (string): 用于唯一标识该项。它对应原生中的 kSecAttrAccount
    • value (string): 要存储的敏感数据。JSBox 会自动将 JavaScript 字符串转换为原生 NSData 并进行加密存储。
    • domain (string, 可选): 强烈推荐使用,用于进一步隔离和识别钥匙串项的来源。它对应原生中的 kSecAttrService
      • 如果提供 domainkey 在你的脚本(更准确地说,在你的 domain)内是唯一的。不同 domain 下可以有相同的 key
      • 如果提供 domain JSBox 会使用一个默认的服务标识符(可能与你的脚本 ID 相关),此时 key 必须在 所有 JSBox 脚本的默认域中 保持唯一,容易发生冲突。
      • 返回: true 表示成功,false 表示失败。
    • 底层行为: 如果 keydomain 组合的项已存在,它会尝试更新该项;否则,会添加新项。JSBox 通常会为新添加的项默认设置 kSecClassGenericPassword 类型和 kSecAttrAccessibleAfterFirstUnlock 可访问性(即设备解锁后始终可用)。
    // 推荐用法:使用自定义 domain (例如你的脚本名或一个唯一 ID)  
    const MY_DOMAIN = "com.myjsbox.reminderapp";  
    const API_KEY = "myApiToken";  
    const API_SECRET = "your_secret_value_123";  
    
    const setSuccess = $keychain.set(API_KEY, API_SECRET, MY_DOMAIN);  
    if (setSuccess) {  
      $ui.toast("API 密钥已安全存储。");  
      console.log("密钥存储成功:", API_KEY);  
    } else {  
      $ui.alert("API 密钥存储失败!");  
      console.error("密钥存储失败:", API_KEY);  
    }  
    
    // 不推荐:不带 domain,可能与其它脚本冲突  
    // $keychain.set("myWeakKey", "weak_value");  
    
  2. $keychain.get(key, domain):获取钥匙串项

    • key (string): 要获取的项的 key
    • domain (string, 可选): 必须与存储时使用的 domain 保持一致。
    • 返回: 存储的 value 字符串,如果未找到或获取失败,则返回 nullundefined
    • 底层行为: 系统会尝试解密并返回数据。如果 Keychain 项配置了生物识别验证(如 Touch ID/Face ID),系统会自动弹出验证提示。
    const MY_DOMAIN = "com.myjsbox.reminderapp";  
    const API_KEY = "myApiToken";  
    
    const storedSecret = $keychain.get(API_KEY, MY_DOMAIN);  
    if (storedSecret) {  
      $ui.alert({ title: "获取到安全密钥", message: storedSecret });  
      console.log("获取到密钥:", storedSecret);  
    } else {  
      $ui.toast("未找到或无法获取安全密钥。");  
      console.warn("密钥获取失败:", API_KEY);  
    }  
    
  3. $keychain.remove(key, domain):移除钥匙串项

    • key (string): 要移除的项的 key
    • domain (string, 可选): 必须与存储时使用的 domain 保持一致。
    • 返回: true 表示成功,false 表示失败。
    const MY_DOMAIN = "com.myjsbox.reminderapp";  
    const API_KEY = "myApiToken";  
    
    const removeSuccess = $keychain.remove(API_KEY, MY_DOMAIN);  
    if (removeSuccess) {  
      $ui.toast("API 密钥已移除。");  
      console.log("密钥移除成功:", API_KEY);  
    } else {  
      $ui.alert("API 密钥移除失败!");  
      console.error("密钥移除失败:", API_KEY);  
    }  
    
  4. $keychain.clear(domain):清除指定域下所有钥匙串项

    • domain (string, 必需): 必须指定一个 domain这个方法无法清除所有域下的所有项,只能清除指定 domain 下的所有项。
    • 返回: true 表示成功,false 表示失败。
    • 重要提示: 这是一个非常危险的操作,会删除该 domain 下的所有钥匙串数据,请谨慎使用。
    const MY_DOMAIN = "com.myjsbox.reminderapp";  
    
    const clearSuccess = $keychain.clear(MY_DOMAIN);  
    if (clearSuccess) {  
      $ui.toast(`域 ${MY_DOMAIN} 下的所有数据已清除。`);  
      console.log("域数据清除成功:", MY_DOMAIN);  
    } else {  
      $ui.alert(`清除域 ${MY_DOMAIN} 数据失败!`);  
      console.error("域数据清除失败:", MY_DOMAIN);  
    }  
    
  5. $keychain.keys(domain):获取指定域下所有钥匙串的 key

    • domain (string, 必需): 必须指定一个 domain
    • 返回: 一个包含所有 key 字符串的数组。
    const MY_DOMAIN = "com.myjsbox.reminderapp";  
    
    const allKeys = $keychain.keys(MY_DOMAIN);  
    $ui.alert({  
      title: `域 ${MY_DOMAIN} 中的所有 Key`,  
      message: allKeys.join(", ") || "无"  
    });  
    console.log(`域 ${MY_DOMAIN} 中的所有 Key:`, allKeys);  
    

B. $keychain 最佳实践与注意事项

  1. 只存储敏感数据: 钥匙串服务是为敏感数据设计的。不要用它来存储非敏感信息,那会增加不必要的开销,且不是其设计目的。非敏感配置和数据应使用 $cache$file
  2. 始终使用 domain 在调用 $keychain.set$keychain.get 时,强烈建议始终提供一个唯一的 domain 参数。这通常是你的脚本名称、脚本 ID、应用包名(如 com.mycompany.myjsboxscript)或其他你确定的唯一字符串。这可以防止你的脚本存储的钥匙串项与其他脚本或应用(如果它们使用了相同的 key 但没有 domain 或使用了冲突的 domain)发生冲突。
  3. 错误处理: $keychain 的方法返回布尔值或 null/undefined 来指示成功或失败。请始终检查这些返回值,并根据需要向用户提供反馈。
  4. 用户密码输入: 不要在代码中硬编码敏感信息。用户的密码或 Token 应该通过 $input.text 等方式获取,然后立即存储到 $keychain 中。
  5. 生物识别验证: 虽然 $keychain API 没有直接暴露设置生物识别的要求(如 kSecAttrAccessControl),但如果你在原生层通过其他方式(如 Xcode 中的 Entitlements 或 Runtime 调用)设置了 Keychain Item,并且该项要求 Touch ID/Face ID,那么当你尝试用 $keychain.get 访问它时,系统会自动弹出验证。JSBox 默认的 set 行为通常不会自动触发每次访问的生物识别验证。
  6. 同步与设备绑定: 理解 Keychain Item 的 Accessibility 选项对 iCloud 同步的影响。如果你存储的数据是设备特有的(例如,一个仅在本设备上有效的 Session Key),则应考虑使用 ThisDeviceOnly 选项(JSBox 默认封装的 $keychain.set 行为通常是 AfterFirstUnlock,会同步)。如果需要更精细的控制,可能需要通过 Runtime 调用原生 API。
  7. $keychain.clear(domain) 的危险性: 这个方法会清空指定 domain 下的所有钥匙串数据。务必在用户确认或明确需要时才调用。

总结 Keychain Services

Keychain Services 是 iOS 平台上用于安全存储敏感数据的强大服务。它凭借硬件支持的加密、精细的访问控制和 iCloud 同步能力,提供了远超文件存储和 UserDefaults 的安全性。

JSBox 的 $keychain API 成功地将这一复杂原生服务简化为易于使用的 JavaScript 接口。作为 JSBox 开发者,掌握 $keychain 的原理和最佳实践,是你构建安全、可靠、用户友好的 JSBox 小程序的关键一步。