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.
This commit is contained in:
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ extension View {
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Log Out", role: .destructive) {
|
||||
session.signOut()
|
||||
Task { await session.signOut() }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
|
||||
Reference in New Issue
Block a user