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 }
|
defer { isDeletingAccount = false }
|
||||||
do {
|
do {
|
||||||
try await apiClient.deleteAccount()
|
try await apiClient.deleteAccount()
|
||||||
session.signOut()
|
await session.signOut()
|
||||||
} catch {
|
} catch {
|
||||||
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
|
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
|
||||||
}
|
}
|
||||||
@@ -1182,6 +1182,11 @@ struct SettingsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
sectionHeader
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||||
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
|
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 static let userPreferencesDefaultsKey = "outline.userPreferences"
|
||||||
|
|
||||||
private let tokenStore: TokenStoring
|
private let tokenStore: TokenStoring
|
||||||
|
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
var isSignedIn: Bool
|
var isSignedIn: Bool
|
||||||
@@ -43,8 +44,13 @@ final class SessionStore {
|
|||||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
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.tokenStore = tokenStore
|
||||||
|
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
@@ -53,7 +59,12 @@ final class SessionStore {
|
|||||||
|
|
||||||
if hasToken, let storedServerURL {
|
if hasToken, let storedServerURL {
|
||||||
isSignedIn = true
|
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)
|
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||||
} else {
|
} else {
|
||||||
// Keychain and the sandboxed UserDefaults container don't
|
// Keychain and the sandboxed UserDefaults container don't
|
||||||
@@ -73,7 +84,12 @@ final class SessionStore {
|
|||||||
|
|
||||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
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)
|
apply(user: user, team: team, serverURL: serverURL)
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
}
|
}
|
||||||
@@ -99,6 +115,7 @@ final class SessionStore {
|
|||||||
private static func makeAPIClient(
|
private static func makeAPIClient(
|
||||||
serverURL: URL,
|
serverURL: URL,
|
||||||
tokenStore: TokenStoring,
|
tokenStore: TokenStoring,
|
||||||
|
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
|
||||||
cache: OfflineCacheStore?
|
cache: OfflineCacheStore?
|
||||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||||
let live = LiveOutlineAPIClient(
|
let live = LiveOutlineAPIClient(
|
||||||
@@ -106,12 +123,22 @@ final class SessionStore {
|
|||||||
tokenStore: tokenStore
|
tokenStore: tokenStore
|
||||||
)
|
)
|
||||||
guard let cache else { return (live, nil) }
|
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)
|
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()
|
try? tokenStore.clear()
|
||||||
|
await cachingClient?.clearEverythingForSignOut()
|
||||||
|
try? cacheEncryptionKeyStore.clear()
|
||||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
userId = nil
|
userId = nil
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ extension View {
|
|||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("Log Out", role: .destructive) {
|
Button("Log Out", role: .destructive) {
|
||||||
session.signOut()
|
Task { await session.signOut() }
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
|
|||||||
Reference in New Issue
Block a user