feat(outlinekit): encrypt the offline cache at rest

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.
This commit is contained in:
2026-08-21 01:15:15 +01:00
parent 1580510cfb
commit 20baab78c0
6 changed files with 320 additions and 47 deletions
@@ -0,0 +1,16 @@
import Foundation
import CryptoKit
/// Boundary over wherever the offline cache's symmetric encryption key
/// lives. Mirrors `TokenStoring` same reasoning, different secret.
public protocol CacheEncryptionKeyStoring: Sendable {
/// Returns the existing key, generating and persisting a new random one
/// on first use if none exists yet.
func key() throws -> SymmetricKey
/// Called on sign-out. Anything still encrypted with the cleared key
/// becomes permanently unreadable that's the point, not a bug: the
/// next person signed in on this machine shouldn't be able to read a
/// previous account's cached content just because the on-disk rows
/// happen to still be there.
func clear() throws
}
@@ -0,0 +1,24 @@
import Foundation
import CryptoKit
/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads
/// from `OfflineCacheStore`. Encryption lives at this layer rather than
/// inside `OfflineCacheStore` itself that stays a dumb opaque-blob store;
/// its `key`/`id`/`kind` columns can't be encrypted without breaking the
/// `#Predicate` queries built directly against them.
enum CachePayloadCryptor {
enum CryptoError: Error {
case sealingFailed
}
static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed }
return combined
}
static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import CryptoKit
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline /// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know /// support at the existing protocol boundary, so no view model needs to know
@@ -36,6 +37,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private let encoder: JSONEncoder private let encoder: JSONEncoder
private let decoder: JSONDecoder private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder private let keyEncoder: JSONEncoder
private let encryptionKeyStore: CacheEncryptionKeyStoring
/// Resolved lazily and kept for this instance's lifetime a fresh
/// instance is created on every sign-in/sign-out anyway (see
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
/// one fewer Keychain round-trip per cache read/write.
private var cachedEncryptionKey: SymmetricKey?
/// Recent failure timestamps per category see `recordFailure` / /// Recent failure timestamps per category see `recordFailure` /
/// `repeatedFailureSummaries()`. A category only shows up there once it's /// `repeatedFailureSummaries()`. A category only shows up there once it's
@@ -47,10 +54,16 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private let failureWindow: TimeInterval = 300 private let failureWindow: TimeInterval = 300
private let failureThreshold: Int = 3 private let failureThreshold: Int = 3
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { public init(
live: OutlineAPIClient,
cache: OfflineCacheStore,
defaults: UserDefaults = .standard,
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
) {
self.live = live self.live = live
self.cache = cache self.cache = cache
self.defaults = defaults self.defaults = defaults
self.encryptionKeyStore = encryptionKeyStore
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
@@ -495,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
await cache.clearAll() await cache.clearAll()
} }
/// Sign-out only see `OfflineCacheStore.clearEverything()` and
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
/// encryption key too (this actor doesn't own that decision); wiping
/// the storage here without it would leave the key to be reused by
/// whoever signs in next.
public func clearEverythingForSignOut() async {
await cache.clearEverything()
}
/// Replays every queued operation against `live`, in the order they were /// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest. /// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary { public func flushPendingOperations() async -> SyncFlushSummary {
@@ -603,13 +625,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// SwiftData read, no network involved. /// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] { public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:") let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) } return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) }
} }
/// Every individually cached collection from the last Full Local Sync. /// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] { public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:") let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) } return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
} }
// MARK: - Helpers // MARK: - Helpers
@@ -621,7 +643,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// the device actually had a connection, defeating the point of // the device actually had a connection, defeating the point of
// deliberately testing/working as if offline. // deliberately testing/working as if offline.
if isManualOfflineModeEnabled { if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached return cached
} }
throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
@@ -629,7 +651,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
do { do {
let result = try await RetryPolicy.withRetry { try await fetch() } let result = try await RetryPolicy.withRetry { try await fetch() }
recordSuccess(category: category) recordSuccess(category: category)
if let data = try? encoder.encode(result) { if let data = encryptedEncode(result) {
await cache.save(data, forKey: key) await cache.save(data, forKey: key)
} }
return result return result
@@ -642,13 +664,48 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if !RetryPolicy.isRetryable(error) { if !RetryPolicy.isRetryable(error) {
recordFailure(category: category, message: errorDescription(error)) recordFailure(category: category, message: errorDescription(error))
} }
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached return cached
} }
throw error throw error
} }
} }
private func resolvedEncryptionKey() throws -> SymmetricKey {
if let cachedEncryptionKey { return cachedEncryptionKey }
let key = try encryptionKeyStore.key()
cachedEncryptionKey = key
return key
}
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
/// (matches the shape of the plain `try? encoder.encode(...)` this
/// replaces) a Keychain hiccup here should behave exactly like an
/// encode failure already did: skip caching this one value, not crash.
private func encryptedEncode(_ value: some Encodable) -> Data? {
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
return try? CachePayloadCryptor.encrypt(plain, key: key)
}
/// Decrypts + decodes a value previously written by `encryptedEncode`.
/// A row written before this feature shipped (still plaintext JSON, or
/// anything encrypted under a key that's since been cleared by
/// sign-out) fails to decrypt and returns `nil` here same as any
/// other decode failure, so `cachedFetch` treats it as a cache miss and
/// refetches, not a crash.
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
return try? decoder.decode(type, from: plain)
}
/// Throwing counterpart for `replay(_:)`, where a genuine decode
/// failure needs to propagate (so `flushPendingOperations` records it
/// as a failed sync attempt) instead of silently vanishing.
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
return try decoder.decode(type, from: plain)
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String { private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix return prefix
@@ -657,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
private func cacheDocument(_ document: OutlineDocument) async { private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) { if let data = encryptedEncode(document) {
await cache.save(data, forKey: "document:\(document.id)") await cache.save(data, forKey: "document:\(document.id)")
} }
} }
private func cacheCollection(_ collection: OutlineCollection) async { private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) { if let data = encryptedEncode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)") await cache.save(data, forKey: "collection:\(collection.id)")
} }
} }
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return } guard let data = encryptedEncode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
} }
@@ -708,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"), guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
@@ -737,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// of it and the update would just fail every retry. // of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"), if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) { let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest( let resolvedCreate = CreateDocumentRequest(
title: merged.title, title: merged.title,
text: merged.text, text: merged.text,
@@ -759,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"), guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
let merged = OutlineCollection( let merged = OutlineCollection(
@@ -809,40 +866,40 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
switch kind { switch kind {
case .createDocument: case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request) let result = try await live.createDocument(request)
await cacheDocument(result) await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan // The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again. // nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)") await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument: case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request) let result = try await live.updateDocument(request)
await cacheDocument(result) await cacheDocument(result)
case .updateCollection: case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request) let result = try await live.updateCollection(request)
await cacheCollection(result) await cacheCollection(result)
case .createPin: case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request) _ = try await live.createPin(request)
case .deletePin: case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id) try await live.deletePin(id: request.id)
case .createSubscription: case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request) _ = try await live.createSubscription(request)
case .deleteSubscription: case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id) try await live.deleteSubscription(id: request.id)
case .starDocument: case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request) _ = try await live.starDocument(request)
case .deleteStar: case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id) try await live.deleteStar(id: request.id)
case .starCollection: case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request) _ = try await live.starCollection(request)
} }
} }
@@ -0,0 +1,76 @@
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
]
}
}
@@ -67,6 +67,22 @@ public actor OfflineCacheStore {
try? modelContext.save() try? modelContext.save()
} }
/// Wipes both the read-through cache AND the pending write queue
/// used only on sign-out (see `SessionStore.signOut()`), since the
/// encryption key backing every row here is about to be cleared too.
/// Anything left un-wiped would just become permanently undecryptable
/// garbage instead of readable by the next account signed in on this
/// machine deliberately more thorough than `clearAll()` (Settings'
/// "Clear All Cache", which never touches pending writes; that button
/// shouldn't silently discard someone's unsynced edits).
public func clearEverything() {
let cachedDescriptor = FetchDescriptor<CachedPayload>()
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
let pendingDescriptor = FetchDescriptor<PendingOperation>()
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue // MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing /// Upserts by `id` a second call with the same id (an edit coalescing
@@ -1,4 +1,5 @@
import XCTest import XCTest
import CryptoKit
@testable import OutlineKit @testable import OutlineKit
private struct NotStubbed: Error {} private struct NotStubbed: Error {}
@@ -116,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
private struct StubTransportError: Error {} private struct StubTransportError: Error {}
/// In-memory `CacheEncryptionKeyStoring` tests must never touch the real
/// Keychain (would pollute the developer's machine and can hang/fail in a
/// sandboxed CI runner with no Keychain access).
private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private var stored: SymmetricKey?
func key() throws -> SymmetricKey {
if let stored { return stored }
let generated = SymmetricKey(size: .bits256)
stored = generated
return generated
}
func clear() throws { stored = nil }
}
final class CachingOutlineAPIClientTests: XCTestCase { final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore { private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
@@ -145,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
let document = makeDocument() let document = makeDocument()
stub.documentInfoHandler = { _ in document } stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let result = try await sut.documentInfo(id: "doc-1") let result = try await sut.documentInfo(id: "doc-1")
@@ -161,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return document } if callCount == 1 { return document }
throw StubTransportError() throw StubTransportError()
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// First call succeeds and populates the cache. // First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -175,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() } stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do { do {
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -194,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return collections } if callCount == 1 { return collections }
throw StubTransportError() throw StubTransportError()
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.listCollections(offset: 0, limit: 25) _ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25) let result = try await sut.listCollections(offset: 0, limit: 25)
@@ -207,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let docOne = makeDocument(id: "doc-1", title: "One") let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two") let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2") _ = try await sut.documentInfo(id: "doc-2")
@@ -227,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Populate the cache with the base document first (as a real open would). // Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -248,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do { do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
@@ -263,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
@@ -276,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() } stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-")) XCTAssertTrue(pin.id.hasPrefix("pending-"))
@@ -294,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -318,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -345,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
liveCallCount += 1 liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
@@ -370,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return [cachedCollection] return [cachedCollection]
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Online first populates the cache normally. // Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25) let firstResult = try await sut.listCollections(offset: 0, limit: 25)
@@ -389,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCacheStorageSummaryReflectsCachedItems() async throws { func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() } stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary() let summary = await sut.cacheStorageSummary()
@@ -418,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") } return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
} }
stub.listDocumentsHandler = { _, _, _, _ in [] } stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
@@ -439,7 +456,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : [] return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2) XCTAssertEqual(summary.documentsCount, 2)
@@ -469,7 +486,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
} }
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
@@ -484,7 +501,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws { func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -508,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1") CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
@@ -528,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws { func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -563,7 +580,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws { func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 { for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1") _ = try? await sut.documentInfo(id: "doc-1")
@@ -580,7 +597,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testFewerThanThresholdFailuresDoNotSurface() async throws { func testFewerThanThresholdFailuresDoNotSurface() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<2 { for _ in 0..<2 {
_ = try? await sut.documentInfo(id: "doc-1") _ = try? await sut.documentInfo(id: "doc-1")
@@ -601,7 +618,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) } if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
return document return document
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 { for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1") _ = try? await sut.documentInfo(id: "doc-1")
@@ -621,7 +638,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws { func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) } stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try? await sut.documentInfo(id: "doc-1") _ = try? await sut.documentInfo(id: "doc-1")
@@ -637,7 +654,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Document reads cross the threshold... // Document reads cross the threshold...
for _ in 0..<3 { for _ in 0..<3 {
@@ -651,4 +668,71 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let summaries = await sut.repeatedFailureSummaries() let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.map(\.category), ["document"]) XCTAssertEqual(summaries.map(\.category), ["document"])
} }
// MARK: - At-rest encryption
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument(title: "Secret Title")
stub.documentInfoHandler = { _ in document }
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
let raw = await cache.load(forKey: "document:doc-1")
XCTAssertNotNil(raw)
// A plain JSON encode would contain the literal title text in the
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
}
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
/// same shape as `SessionStore.makeAPIClient` always creating a new
/// instance) anything still on disk from before is unreadable under
/// the new key, which is the entire point of clearing it on sign-out.
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let cache = try makeCache()
let keyStore = StaticCacheEncryptionKeyStore()
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
_ = try await beforeSignOut.documentInfo(id: "doc-1")
try keyStore.clear()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
do {
_ = try await afterSignIn.documentInfo(id: "doc-1")
XCTFail("Expected the now-undecryptable cache entry to be unusable")
} catch is StubTransportError {
// expected live fails, and the leftover cache entry can't be
// decrypted under the new key either, so there's no fallback.
}
}
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try? await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Offline edit"))
let summaryBefore = await sut.cacheStorageSummary()
let pendingBefore = await sut.pendingOperations()
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
XCTAssertFalse(pendingBefore.isEmpty)
await sut.clearEverythingForSignOut()
let summaryAfter = await sut.cacheStorageSummary()
let pendingAfter = await sut.pendingOperations()
XCTAssertEqual(summaryAfter.itemCount, 0)
XCTAssertTrue(pendingAfter.isEmpty)
}
} }