ios-security by dpearson2699/swift-ios-skills
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill ios-security关于在 iOS 上处理敏感数据、用户身份验证、正确加密以及遵循 Apple 安全最佳实践的指南。
Keychain 是存储敏感数据的唯一正确位置。切勿将密码、令牌、API 密钥或机密信息存储在 UserDefaults、文件或 Core Data 中。
func saveToKeychain(account: String, data: Data, service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
let updates: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updates as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.updateFailed(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.saveFailed(status)
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
func readFromKeychain(account: String, service: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
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 else {
throw KeychainError.readFailed(status)
}
return data
}
func deleteFromKeychain(account: String, service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
| 取值 | 何时可用 | 仅限本设备 | 用途 |
|---|---|---|---|
kSecAttrAccessibleWhenUnlocked | 设备解锁时 | 否 | 通用凭据 |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | 设备解锁时 | 是 | 敏感凭据 |
kSecAttrAccessibleAfterFirstUnlock | 首次解锁后 | 否 | 后台可访问的令牌 |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | 首次解锁后 | 是 | 后台令牌,不备份 |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | 已设置密码 + 解锁时 | 是 | 最高安全性 |
规则:
ThisDeviceOnly 变体。防止备份/恢复到其他设备。AfterFirstUnlock。WhenPasscodeSetThisDeviceOnly。如果移除密码,项目将被删除。kSecAttrAccessibleAlways(已弃用且不安全)。在同一团队的应用之间共享 Keychain 项目:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "shared-token",
kSecAttrAccessGroup as String: "TEAMID.com.company.shared"
]
| 存储方式 | 用途 | 安全性 |
|---|---|---|
@AppStorage / UserDefaults | 非敏感偏好设置(主题、引导状态、功能开关) | 静态时未加密 |
| Keychain | 密码、令牌、API 密钥、机密信息 | 硬件加密,访问控制 |
规则: 如果数据泄露会造成尴尬或危险,则应放入 Keychain。其他所有数据都可以使用 @AppStorage。
// 非敏感偏好设置 -- 使用 @AppStorage 即可
@AppStorage("hasCompletedOnboarding") private var hasOnboarded = false
// 敏感凭据 -- 必须使用 Keychain
// 错误:@AppStorage("authToken") private var token = ""
// 正确:使用 saveToKeychain(account:data:service:)
iOS 根据文件的保护类别对其进行加密:
| 类别 | 何时可用 | 用途 |
|---|---|---|
.complete | 仅解锁时 | 敏感用户数据 |
.completeUnlessOpen | 已打开的文件句柄在锁屏后仍有效 | 活动下载、录音 |
.completeUntilFirstUserAuthentication | 首次解锁后(默认) | 大多数应用数据 |
.none | 始终 | 非敏感、系统所需数据 |
// 设置文件保护
try data.write(to: url, options: .completeFileProtection)
// 检查保护级别
let attributes = try FileManager.default.attributesOfItem(atPath: path)
let protection = attributes[.protectionKey] as? FileProtectionType
对于任何包含用户敏感数据的文件,请使用 .complete。对于一般的应用数据,默认的 .completeUntilFirstUserAuthentication 是可以接受的。
对所有加密操作使用 CryptoKit。对于新代码,请勿使用 CommonCrypto 或原始的 Security 框架。
import CryptoKit
let key = SymmetricKey(size: .bits256)
func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealed = try AES.GCM.seal(data, using: key)
guard let combined = sealed.combined else {
throw CryptoError.sealFailed
}
return combined
}
func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let box = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(box, using: key)
}
let hash = SHA256.hash(data: data)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
// 同样可用:SHA384, SHA512
let key = SymmetricKey(size: .bits256)
// 签名
let signature = HMAC<SHA256>.authenticationCode(for: data, using: key)
// 验证
let isValid = HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: data, using: key)
关于数字签名、密钥协商、ChaChaPoly 和 HKDF 密钥派生,请参阅 references/cryptokit-advanced.md。
为了获得最高安全性,请将密钥存储在 Secure Enclave 中。密钥永远不会离开硬件。仅支持 P256。
guard SecureEnclave.isAvailable else { return }
let accessControl = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], nil
)!
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl)
let signature = try privateKey.signature(for: data) // 可能触发生物识别提示
let isValid = privateKey.publicKey.isValidSignature(signature, for: data)
// 持久化:将 dataRepresentation 存储在 Keychain 中,并通过以下方式恢复:
let restored = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
本节涵盖对 Keychain 项目和数据访问的生物识别保护。关于面向用户的生物识别登录流程,请参阅 authentication 技能。
import LocalAuthentication
func authenticateWithBiometrics() async throws -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics, error: &error
) else {
// 生物识别不可用 -- 回退到密码
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Authenticate to access your account"
)
}
throw AuthError.biometricsUnavailable
}
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
)
}
你必须在 Info.plist 中包含 NSFaceIDUsageDescription:
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your secure data</string>
缺少此键会在 Face ID 设备上导致崩溃。
let context = LAContext()
context.localizedFallbackTitle = "Use Passcode"
context.touchIDAuthenticationAllowableReuseDuration = 30
let currentState = context.evaluatedPolicyDomainState // 比较以检测注册变更
使用生物识别访问保护 Keychain 项目:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "auth-token",
kSecValueData as String: tokenData,
kSecAttrAccessControl as String: access,
kSecUseAuthenticationContext as String: LAContext()
]
SecAccessControl 标志:
.biometryCurrentSet -- 需要生物识别,如果注册信息变更则失效。最安全。.biometryAny -- 需要生物识别,注册信息变更后仍有效。.userPresence -- 生物识别或密码。最灵活。ATS 默认强制使用 HTTPS。请不要禁用它。
<!-- 仅适用于你无法升级的遗留服务器 -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
</dict>
</dict>
</dict>
规则:
NSAllowsArbitraryLoads 设置为 true。Apple 会拒绝该应用。为敏感 API 连接锁定证书,以防止中间人攻击。
import CryptoKit
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
// 证书 Subject Public Key Info 的 SHA-256 哈希值
private let pinnedHashes: Set<String> = [
"base64EncodedSHA256HashOfSPKI=="
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard let trust = challenge.protectionSpace.serverTrust,
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let certificate = chain.first else {
return (.cancelAuthenticationChallenge, nil)
}
guard let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(
publicKey, nil
) as Data? else {
return (.cancelAuthenticationChallenge, nil)
}
let hash = SHA256.hash(data: publicKeyData)
let hashString = Data(hash).base64EncodedString()
if pinnedHashes.contains(hashString) {
return (.useCredential, URLCredential(trust: trust))
}
return (.cancelAuthenticationChallenge, nil)
}
}
规则:
// 错误
logger.debug("User logged in with token: \(token)")
// 正确
logger.debug("User logged in successfully")
var sensitiveData = Data(/* ... */)
defer {
sensitiveData.resetBytes(in: 0..<sensitiveData.count)
}
guard let url = URL(string: input),
["https"].contains(url.scheme?.lowercased()) else {
throw SecurityError.invalidURL
}
let resolved = url.standardized.path
guard resolved.hasPrefix(allowedDirectory.path) else {
throw SecurityError.pathTraversal
}
使用 #error 防止意外提交占位符 API 密钥:
// 在配置真实密钥之前强制构建错误
#error("Add your API key to Secrets.plist -- see README for setup")
private let apiKey = Secrets.value(for: "API_KEY")
检查已知的越狱文件路径(/Applications/Cydia.app、/usr/sbin/sshd 等)和沙箱逃逸。越狱检测并非万无一失——将其作为一层防护,而不是唯一的防护层。完整实现请参阅 references/cryptokit-advanced.md。
应用和 SDK 必须在 PrivacyInfo.xcprivacy 中声明数据访问。关于必需理由 API 声明和安全相关数据收集的详细信息,请参阅 references/privacy-manifest.md。关于提交要求和合规性检查清单,请参阅 references/app-review-guidelines.md。
NSAllowsArbitraryLoads = true 有被拒绝的风险。.biometryCurrentSet 时使用了 .biometryAny。 前者在注册信息变更后仍有效,这对于高安全性项目可能是不希望的。@MainActor。kSecAttrAccessible 值;对不备份的数据使用 ThisDeviceOnly.complete)NSFaceIDUsageDescriptionSecAccessControl 标志;配置了 LAContextNSAllowsArbitraryLoads;为敏感 API 进行证书锁定references/cryptokit-advanced.mdreferences/privacy-manifest.mdreferences/app-review-guidelines.mdreferences/file-storage-patterns.md每周安装量
409
仓库
GitHub 星标数
269
首次出现
2026年3月3日
安全审计
安装在
codex406
gemini-cli403
amp403
cline403
github-copilot403
kimi-cli403
Guidance for handling sensitive data, authenticating users, encrypting correctly, and following Apple's security best practices on iOS.
The Keychain is the ONLY correct place to store sensitive data. Never store passwords, tokens, API keys, or secrets in UserDefaults, files, or Core Data.
func saveToKeychain(account: String, data: Data, service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
let updates: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updates as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.updateFailed(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.saveFailed(status)
}
}
func readFromKeychain(account: String, service: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
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 else {
throw KeychainError.readFailed(status)
}
return data
}
func deleteFromKeychain(account: String, service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
| Value | When Available | Device-Only | Use For |
|---|---|---|---|
kSecAttrAccessibleWhenUnlocked | Device unlocked | No | General credentials |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Device unlocked | Yes | Sensitive credentials |
kSecAttrAccessibleAfterFirstUnlock | After first unlock | No | Background-accessible tokens |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock |
Rules:
ThisDeviceOnly variants for sensitive data. Prevents backup/restore to other devices.AfterFirstUnlock for tokens needed by background operations.WhenPasscodeSetThisDeviceOnly for most sensitive data. Item is deleted if passcode is removed.kSecAttrAccessibleAlways (deprecated and insecure).Share keychain items across apps from the same team:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "shared-token",
kSecAttrAccessGroup as String: "TEAMID.com.company.shared"
]
| Storage | Use For | Security |
|---|---|---|
@AppStorage / UserDefaults | Non-sensitive preferences (theme, onboarding state, feature flags) | Not encrypted at rest |
| Keychain | Passwords, tokens, API keys, secrets | Hardware-encrypted, access-controlled |
Rule: If the data would be embarrassing or dangerous if exposed, it goes in Keychain. Everything else can use @AppStorage.
// Non-sensitive preference -- @AppStorage is fine
@AppStorage("hasCompletedOnboarding") private var hasOnboarded = false
// Sensitive credential -- MUST use Keychain
// WRONG: @AppStorage("authToken") private var token = ""
// CORRECT: Use saveToKeychain(account:data:service:)
iOS encrypts files based on their protection class:
| Class | When Available | Use For |
|---|---|---|
.complete | Only when unlocked | Sensitive user data |
.completeUnlessOpen | Open handles survive lock | Active downloads, recordings |
.completeUntilFirstUserAuthentication | After first unlock (default) | Most app data |
.none | Always | Non-sensitive, system-needed data |
// Set file protection
try data.write(to: url, options: .completeFileProtection)
// Check protection level
let attributes = try FileManager.default.attributesOfItem(atPath: path)
let protection = attributes[.protectionKey] as? FileProtectionType
Use .complete for any file containing user-sensitive data. The default .completeUntilFirstUserAuthentication is acceptable for general app data.
Use CryptoKit for all cryptographic operations. Do not use CommonCrypto or the raw Security framework for new code.
import CryptoKit
let key = SymmetricKey(size: .bits256)
func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealed = try AES.GCM.seal(data, using: key)
guard let combined = sealed.combined else {
throw CryptoError.sealFailed
}
return combined
}
func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let box = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(box, using: key)
}
let hash = SHA256.hash(data: data)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
// Also available: SHA384, SHA512
let key = SymmetricKey(size: .bits256)
// Sign
let signature = HMAC<SHA256>.authenticationCode(for: data, using: key)
// Verify
let isValid = HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: data, using: key)
For digital signatures (P256/ECDSA), key agreement (Curve25519), ChaChaPoly, and HKDF key derivation, see references/cryptokit-advanced.md.
For the highest security, store keys in the Secure Enclave. Keys never leave the hardware. Only P256 is supported.
guard SecureEnclave.isAvailable else { return }
let accessControl = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], nil
)!
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl)
let signature = try privateKey.signature(for: data) // May trigger biometric prompt
let isValid = privateKey.publicKey.isValidSignature(signature, for: data)
// Persist: store dataRepresentation in Keychain, restore with:
let restored = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
This section covers biometric protection for Keychain items and data access. For user-facing biometric sign-in flows (LAContext.evaluatePolicy as a login mechanism), see the authentication skill.
import LocalAuthentication
func authenticateWithBiometrics() async throws -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics, error: &error
) else {
// Biometrics not available -- fall back to passcode
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Authenticate to access your account"
)
}
throw AuthError.biometricsUnavailable
}
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
)
}
You MUST include NSFaceIDUsageDescription in Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your secure data</string>
Missing this key causes a crash on Face ID devices.
let context = LAContext()
context.localizedFallbackTitle = "Use Passcode"
context.touchIDAuthenticationAllowableReuseDuration = 30
let currentState = context.evaluatedPolicyDomainState // Compare to detect enrollment changes
Protect keychain items with biometric access:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "auth-token",
kSecValueData as String: tokenData,
kSecAttrAccessControl as String: access,
kSecUseAuthenticationContext as String: LAContext()
]
SecAccessControl flags:
.biometryCurrentSet -- Requires biometry, invalidated if enrollment changes. Most secure..biometryAny -- Requires biometry, survives enrollment changes..userPresence -- Biometry or passcode. Most flexible.ATS enforces HTTPS by default. Do NOT disable it.
<!-- Only for legacy servers you cannot upgrade -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
</dict>
</dict>
</dict>
Rules:
NSAllowsArbitraryLoads to true. Apple will reject the app.Pin certificates for sensitive API connections to prevent MITM attacks.
import CryptoKit
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
// SHA-256 hash of the certificate's Subject Public Key Info
private let pinnedHashes: Set<String> = [
"base64EncodedSHA256HashOfSPKI=="
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard let trust = challenge.protectionSpace.serverTrust,
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let certificate = chain.first else {
return (.cancelAuthenticationChallenge, nil)
}
guard let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(
publicKey, nil
) as Data? else {
return (.cancelAuthenticationChallenge, nil)
}
let hash = SHA256.hash(data: publicKeyData)
let hashString = Data(hash).base64EncodedString()
if pinnedHashes.contains(hashString) {
return (.useCredential, URLCredential(trust: trust))
}
return (.cancelAuthenticationChallenge, nil)
}
}
Rules:
// WRONG
logger.debug("User logged in with token: \(token)")
// CORRECT
logger.debug("User logged in successfully")
var sensitiveData = Data(/* ... */)
defer {
sensitiveData.resetBytes(in: 0..<sensitiveData.count)
}
guard let url = URL(string: input),
["https"].contains(url.scheme?.lowercased()) else {
throw SecurityError.invalidURL
}
let resolved = url.standardized.path
guard resolved.hasPrefix(allowedDirectory.path) else {
throw SecurityError.pathTraversal
}
Use #error to prevent accidental commits of placeholder API keys:
// Forces a build error until the real key is configured
#error("Add your API key to Secrets.plist -- see README for setup")
private let apiKey = Secrets.value(for: "API_KEY")
Check for known jailbreak file paths (/Applications/Cydia.app, /usr/sbin/sshd, etc.) and sandbox escape. Jailbreak detection is not foolproof -- use it as one layer, not the only layer. See references/cryptokit-advanced.md for full implementation.
Apps and SDKs must declare data access in PrivacyInfo.xcprivacy. See references/privacy-manifest.md for required-reason API declarations and security-related data collection details. For submission requirements and compliance checklists, see references/app-review-guidelines.md.
NSAllowsArbitraryLoads = true is a rejection risk..biometryAny when .biometryCurrentSet is needed. The former survives enrollment changes, which may be undesirable for high-security items.@MainActor.kSecAttrAccessible value; ThisDeviceOnly for non-backup data.complete)NSFaceIDUsageDescription in Info.plistSecAccessControl flags; LAContext configuredNSAllowsArbitraryLoads; cert pinning for sensitive APIsreferences/cryptokit-advanced.mdreferences/privacy-manifest.mdreferences/app-review-guidelines.mdreferences/file-storage-patterns.mdWeekly Installs
409
Repository
GitHub Stars
269
First Seen
Mar 3, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex406
gemini-cli403
amp403
cline403
github-copilot403
kimi-cli403
xdrop 文件传输脚本:Bun 环境下安全上传下载工具,支持加密分享
20,700 周安装
| Yes |
| Background tokens, no backup |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Passcode set + unlocked | Yes | Highest security |