Dùng Keychain trực tiếp, không cần thư viện bọc
Mọi dự án iOS tôi từng tham gia đều có một lớp bọc Keychain — hoặc một phụ thuộc hoặc một file được chép vào — và trong mọi trường hợp, cái lớp bọc đều lớn hơn thứ nó bọc.
API của Keychain thật sự khó chịu: hàm C, CFDictionary, các giá trị Any không kiểu, và mã lỗi
thay vì các lỗi. Nó cũng nhỏ. Đây là toàn bộ những gì phần lớn ứng dụng cần.
Bốn thao tác
import Security
enum Keychain {
enum Error: Swift.Error {
case unexpectedStatus(OSStatus)
}
static func set(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
var newItem = query
newItem.merge(attributes) { current, _ in current }
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw Error.unexpectedStatus(addStatus) }
default:
throw Error.unexpectedStatus(status)
}
}
static func get(_ key: String) throws -> Data? {
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)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
default:
throw Error.unexpectedStatus(status)
}
}
static func delete(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw Error.unexpectedStatus(status)
}
}
}
Sáu mươi dòng, không phụ thuộc, và bạn đọc được toàn bộ.
Ba chi tiết quan trọng
SecItemAdd hỏng khi trùng. Nó trả về errSecDuplicateItem chứ không ghi đè, và đó là lý do hàm
set ở trên thử SecItemUpdate trước rồi mới lùi về việc thêm mới. Những lớp bọc gọi delete rồi
add có một khoảng thời gian mà giá trị không tồn tại, điều đó quan trọng nếu hai luồng chạy đua.
Hằng số về khả năng truy cập là một quyết định bảo mật, không phải một giá trị mặc định. Đây là thứ người ta hay làm sai:
| Hằng số | Truy cập được khi |
|---|---|
kSecAttrAccessibleWhenUnlocked |
Chỉ khi thiết bị đang mở khóa |
kSecAttrAccessibleAfterFirstUnlock |
Sau lần mở khóa đầu tiên kể từ lúc khởi động — cần cho việc chạy nền |
Các biến thể …ThisDeviceOnly |
Tương tự, nhưng bị loại khỏi bản sao lưu |
Mặc định nếu bạn bỏ qua nó là kSecAttrAccessibleWhenUnlocked, nghĩa là một lượt làm mới chạy nền
lúc 3 giờ sáng không đọc được token xác thực của bạn. Đó là nguyên nhân của các báo cáo “ứng dụng
đăng xuất tôi ra qua đêm”.
AfterFirstUnlock là câu trả lời đúng cho một token mà code chạy nền cần đến.
WhenUnlockedThisDeviceOnly đúng cho thứ không bao giờ được rời khỏi thiết bị, đổi lại người dùng
mất nó khi khôi phục sang máy mới.
Cảnh báo
Các mục trong Keychain sống sót qua việc xóa ứng dụng. Gỡ ứng dụng, cài lại, và cái token cũ
vẫn còn đó. Thỉnh thoảng đó là thứ bạn muốn và thường thì đó là một bất ngờ — đó là lý do người
kiểm thử báo rằng họ “vẫn đang đăng nhập” sau một lần cài mới tinh. Nếu bạn muốn trạng thái sạch
khi cài, hãy xóa Keychain ở lần chạy đầu tiên bằng một cờ trong UserDefaults, thứ không sống
sót qua việc gỡ.
Làm cho nó có kiểu
Hai extension lo được gần như mọi cách dùng:
extension Keychain {
static func setString(_ string: String, for key: String) throws {
try set(Data(string.utf8), for: key)
}
static func string(_ key: String) throws -> String? {
try get(key).flatMap { String(data: $0, encoding: .utf8) }
}
static func setCodable<T: Encodable>(_ value: T, for key: String) throws {
try set(JSONEncoder().encode(value), for: key)
}
static func codable<T: Decodable>(_ type: T.Type, for key: String) throws -> T? {
try get(key).map { try JSONDecoder().decode(T.self, from: $0) }
}
}
Đó là toàn bộ bề mặt API của phần lớn các thư viện bọc.
Một lớp bọc thật sự dùng để làm gì
Tôi không muốn lập luận rằng phụ thuộc thì luôn sai. Một lớp bọc xứng đáng có mặt nếu bạn cần:
- Access group, để chia sẻ giữa một ứng dụng và các extension của nó
- Đồng bộ iCloud Keychain (
kSecAttrSynchronizable) - Bảo vệ bằng sinh trắc học qua
SecAccessControlvàLAContext - Chứng chỉ và khóa, không chỉ mật khẩu
Những thứ đó thật sự rắc rối và một thư viện được kiểm thử kỹ thì đáng dùng. Nhưng nếu thứ bạn cần là
“lưu một token, đọc một token, xóa một token” thì đoạn code ở trên là tất cả — và sáu mươi dòng bạn
sở hữu thì dễ gỡ lỗi hơn một phụ thuộc, vì khi một lời gọi Keychain hỏng thì thông tin hữu ích chính
là cái OSStatus, mà phần lớn lớp bọc lại vứt nó đi.