Both CachedPayload and PendingOperation only ever stored their payload as plain JSON on disk (SwiftData/SQLite, no encryption of its own) - readable by anyone with access to the logged-in session, per the earlier discussion on where this cache lives. Adds AES-GCM encryption at the one place raw bytes cross into/out of OfflineCacheStore (CachingOutlineAPIClient, which already owns encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob store, since its key/id/kind columns can't be encrypted without breaking the #Predicate queries built against them. Key management (KeychainCacheEncryptionKeyStore, mirrors KeychainTokenStore exactly): a random 256-bit key, generated once and Keychain-stored, not derived from anything guessable. It doesn't need deriving to survive an uninstall/reinstall either - Keychain items are scoped to the app's code signature, not its on-disk presence, so a reinstall of the same app regains access to the same key automatically (same reason a saved API token already survives a reinstall today). If the on-disk cache also happens to survive (dragging the .app to the Trash doesn't clean ~/Library/Containers), a reinstall can still read it. A row written before this shipped (still plaintext) or encrypted under a since-cleared key just fails to decrypt and is treated as a cache miss - same as any other decode failure, so it silently refetches and re-caches encrypted rather than crashing. No explicit migration needed. Also: OfflineCacheStore.clearEverything() wipes both the cache AND the pending write queue (clearAll(), used by Settings' "Clear All Cache", still only touches the cache - it shouldn't silently discard someone's unsynced edits). Exposed as CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to use alongside clearing the key. CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests never touch the real Keychain - existing CachingOutlineAPIClientTests now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests (retry/failure-log tests from the previous commit plus 3 new ones here: ciphertext isn't plaintext JSON, a cleared key makes old rows unreadable, clearEverythingForSignOut wipes both tables). 95/95 passing.
77 lines
3.1 KiB
Swift
77 lines
3.1 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
import Security
|
|
|
|
/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain
|
|
/// random 256-bit value — deliberately NOT derived from anything guessable
|
|
/// (bundle id, device id, etc.). It doesn't need deriving to survive an
|
|
/// uninstall/reinstall of the app either: Keychain items are scoped to the
|
|
/// requesting app's code signature (bundle id + team id), not its on-disk
|
|
/// presence, so reinstalling the same app regains access to the same
|
|
/// Keychain item automatically — exactly how `KeychainTokenStore`'s saved
|
|
/// token already survives a reinstall today. If the on-disk cache also
|
|
/// happens to survive (macOS doesn't clean out `~/Library/Containers` just
|
|
/// because the .app bundle was dragged to the Trash), the reinstalled app
|
|
/// can still decrypt it; a different app, or the same app after an explicit
|
|
/// sign-out (`clear()`), can't.
|
|
public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
|
|
private let service: String
|
|
private let account: String
|
|
|
|
public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") {
|
|
self.service = service
|
|
self.account = account
|
|
}
|
|
|
|
public func key() throws -> SymmetricKey {
|
|
if let existing = try? readKey() { return existing }
|
|
let generated = SymmetricKey(size: .bits256)
|
|
try store(generated)
|
|
return generated
|
|
}
|
|
|
|
public func clear() throws {
|
|
let status = SecItemDelete(baseQuery() as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw TokenStoreError.deleteFailed(status)
|
|
}
|
|
}
|
|
|
|
private func readKey() throws -> SymmetricKey {
|
|
var query = baseQuery()
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
guard status == errSecSuccess, let data = result as? Data else {
|
|
throw TokenStoreError.notFound
|
|
}
|
|
return SymmetricKey(data: data)
|
|
}
|
|
|
|
private func store(_ key: SymmetricKey) throws {
|
|
let data = key.withUnsafeBytes { Data($0) }
|
|
let query = baseQuery()
|
|
|
|
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
|
|
if existsStatus == errSecSuccess {
|
|
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
|
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
|
|
} else {
|
|
var addQuery = query
|
|
addQuery[kSecValueData as String] = data
|
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
|
|
}
|
|
}
|
|
|
|
private func baseQuery() -> [String: Any] {
|
|
[
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account
|
|
]
|
|
}
|
|
}
|