From f624ce6c9f9a59f51a54fc9d7f9254cc6329bb26 Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:15:26 +0100 Subject: [PATCH] feat(app): clear the cache encryption key on sign-out, note it in Settings SessionStore.signOut() now clears the offline cache's Keychain-stored encryption key alongside the API token, and wipes the cache/pending- write storage itself (CachingOutlineAPIClient.clearEverythingForSignOut()) before doing so - so a previous account's cached content isn't sitting there readable (even in principle, if the on-disk rows survive) by whoever signs in next on the same machine. signOut() is async now to do this properly instead of firing a detached Task; both call sites (the logout confirmation dialog, delete-account) updated. Settings -> Offline & Sync now states plainly that the local cache is encrypted at rest and cleared on log out - not compiler-verified (Outpost app target has no CLI build path), worth a look in Xcode. --- Outpost/Features/Account/SettingsView.swift | 7 +++- Outpost/Root/SessionStore.swift | 37 ++++++++++++++++--- Outpost/Support/View+LogoutConfirmation.swift | 2 +- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 6213f0c..b1a62fb 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -618,7 +618,7 @@ struct SettingsView: View { defer { isDeletingAccount = false } do { try await apiClient.deleteAccount() - session.signOut() + await session.signOut() } catch { deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.") } @@ -1182,6 +1182,11 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 20) { sectionHeader + Label("Everything cached here is encrypted at rest with a key stored in Keychain, cleared automatically when you log out.", systemImage: "lock.fill") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: 480, alignment: .leading) + VStack(alignment: .leading, spacing: 6) { Toggle("Offline Mode", isOn: $isOfflineModeEnabled) Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index fa14818..6f6277a 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -15,6 +15,7 @@ final class SessionStore { private static let userPreferencesDefaultsKey = "outline.userPreferences" private let tokenStore: TokenStoring + private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring private let defaults: UserDefaults var isSignedIn: Bool @@ -43,8 +44,13 @@ final class SessionStore { defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) } - init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) { + init( + tokenStore: TokenStoring = KeychainTokenStore(), + cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(), + defaults: UserDefaults = .standard + ) { self.tokenStore = tokenStore + self.cacheEncryptionKeyStore = cacheEncryptionKeyStore self.defaults = defaults self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) @@ -53,7 +59,12 @@ final class SessionStore { if hasToken, let storedServerURL { isSignedIn = true - (apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) + (apiClient, cachingClient) = Self.makeAPIClient( + serverURL: storedServerURL, + tokenStore: tokenStore, + cacheEncryptionKeyStore: cacheEncryptionKeyStore, + cache: cacheStore + ) userPreferences = Self.loadCachedPreferences(defaults: defaults) } else { // Keychain and the sandboxed UserDefaults container don't @@ -73,7 +84,12 @@ final class SessionStore { func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) - (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + (apiClient, cachingClient) = Self.makeAPIClient( + serverURL: serverURL, + tokenStore: tokenStore, + cacheEncryptionKeyStore: cacheEncryptionKeyStore, + cache: cacheStore + ) apply(user: user, team: team, serverURL: serverURL) isSignedIn = true } @@ -99,6 +115,7 @@ final class SessionStore { private static func makeAPIClient( serverURL: URL, tokenStore: TokenStoring, + cacheEncryptionKeyStore: CacheEncryptionKeyStoring, cache: OfflineCacheStore? ) -> (OutlineAPIClient, CachingOutlineAPIClient?) { let live = LiveOutlineAPIClient( @@ -106,12 +123,22 @@ final class SessionStore { tokenStore: tokenStore ) guard let cache else { return (live, nil) } - let caching = CachingOutlineAPIClient(live: live, cache: cache) + let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore) return (caching, caching) } - func signOut() { + /// Clears everything scoped to this sign-in: the API token, the offline + /// cache's encryption key, and the cache/pending-write storage itself + /// (in that order — wiping storage before the key would leave it + /// readable a moment longer than necessary, and wiping the key without + /// the storage would leave permanently-undecryptable rows sitting + /// around instead of actually freeing anything). Whoever signs in next + /// on this machine gets a clean slate, not a previous account's + /// leftover cached content. + func signOut() async { try? tokenStore.clear() + await cachingClient?.clearEverythingForSignOut() + try? cacheEncryptionKeyStore.clear() defaults.removeObject(forKey: Self.serverURLDefaultsKey) isSignedIn = false userId = nil diff --git a/Outpost/Support/View+LogoutConfirmation.swift b/Outpost/Support/View+LogoutConfirmation.swift index 6675154..8fe3b61 100644 --- a/Outpost/Support/View+LogoutConfirmation.swift +++ b/Outpost/Support/View+LogoutConfirmation.swift @@ -8,7 +8,7 @@ extension View { titleVisibility: .visible ) { Button("Log Out", role: .destructive) { - session.signOut() + Task { await session.signOut() } } Button("Cancel", role: .cancel) {} } message: {