Update the SDK (#3196)

* Update the SDK.

* Fix API breaks on send failures and propagate the new type.

* Handle new SDK ShieldState.

* Set up the ClientBuilder's `cachePath` option.

* Delete the cacheDirectory during logout/clearCache.

* Add unit tests for RestorationToken decoding and SessionDirectories generation.
This commit is contained in:
Doug
2024-08-27 11:06:26 +01:00
committed by GitHub
parent ebef0d1fbe
commit 812c5d5c61
26 changed files with 403 additions and 82 deletions
@@ -20,7 +20,7 @@ import MatrixRustSDK
class AuthenticationService: AuthenticationServiceProtocol {
private var client: Client?
private var sessionDirectory: URL
private var sessionDirectories: SessionDirectories
private let passphrase: String
private let userSessionStore: UserSessionStoreProtocol
@@ -31,7 +31,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
init(userSessionStore: UserSessionStoreProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol, appSettings: AppSettings, appHooks: AppHooks) {
sessionDirectory = .sessionsBaseDirectory.appending(component: UUID().uuidString)
sessionDirectories = .init()
passphrase = encryptionKeyProvider.generateKey().base64EncodedString()
self.userSessionStore = userSessionStore
self.appSettings = appSettings
@@ -149,20 +149,24 @@ class AuthenticationService: AuthenticationServiceProtocol {
slidingSyncProxy: appSettings.slidingSyncProxyURL,
sessionDelegate: userSessionStore.clientSessionDelegate,
appHooks: appHooks)
.sessionPath(path: sessionDirectory.path(percentEncoded: false))
.sessionPaths(dataPath: sessionDirectories.dataPath,
cachePath: sessionDirectories.cachePath)
.passphrase(passphrase: passphrase)
}
private func rotateSessionDirectory() {
if FileManager.default.directoryExists(at: sessionDirectory) {
try? FileManager.default.removeItem(at: sessionDirectory)
if FileManager.default.directoryExists(at: sessionDirectories.dataDirectory) {
try? FileManager.default.removeItem(at: sessionDirectories.dataDirectory)
}
if FileManager.default.directoryExists(at: sessionDirectories.cacheDirectory) {
try? FileManager.default.removeItem(at: sessionDirectories.cacheDirectory)
}
sessionDirectory = .sessionsBaseDirectory.appending(component: UUID().uuidString)
sessionDirectories = .init()
}
private func userSession(for client: Client) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
switch await userSessionStore.userSession(for: client, sessionDirectory: sessionDirectory, passphrase: passphrase) {
switch await userSessionStore.userSession(for: client, sessionDirectories: sessionDirectories, passphrase: passphrase) {
case .success(let clientProxy):
return .success(clientProxy)
case .failure:
@@ -120,6 +120,7 @@ class KeychainController: KeychainControllerProtocol {
}
let restorationToken = RestorationToken(session: session,
sessionDirectory: oldToken.sessionDirectory,
cacheDirectory: oldToken.cacheDirectory,
passphrase: oldToken.passphrase,
pusherNotificationClientIdentifier: oldToken.pusherNotificationClientIdentifier)
setRestorationToken(restorationToken, forUsername: session.userId)
@@ -20,7 +20,7 @@ import Foundation
import MatrixRustSDK
final class QRCodeLoginService: QRCodeLoginServiceProtocol {
private var sessionDirectory: URL
private var sessionDirectories: SessionDirectories
private let passphrase: String
private let userSessionStore: UserSessionStoreProtocol
@@ -36,7 +36,7 @@ final class QRCodeLoginService: QRCodeLoginServiceProtocol {
userSessionStore: UserSessionStoreProtocol,
appSettings: AppSettings,
appHooks: AppHooks) {
sessionDirectory = .sessionsBaseDirectory.appending(component: UUID().uuidString)
sessionDirectories = .init()
passphrase = encryptionKeyProvider.generateKey().base64EncodedString()
self.userSessionStore = userSessionStore
self.appSettings = appSettings
@@ -83,20 +83,24 @@ final class QRCodeLoginService: QRCodeLoginServiceProtocol {
slidingSyncProxy: appSettings.slidingSyncProxyURL,
sessionDelegate: userSessionStore.clientSessionDelegate,
appHooks: appHooks)
.sessionPath(path: sessionDirectory.path(percentEncoded: false))
.sessionPaths(dataPath: sessionDirectories.dataPath,
cachePath: sessionDirectories.cachePath)
.passphrase(passphrase: passphrase)
}
private func rotateSessionDirectory() {
if FileManager.default.directoryExists(at: sessionDirectory) {
try? FileManager.default.removeItem(at: sessionDirectory)
if FileManager.default.directoryExists(at: sessionDirectories.dataDirectory) {
try? FileManager.default.removeItem(at: sessionDirectories.dataDirectory)
}
if FileManager.default.directoryExists(at: sessionDirectories.cacheDirectory) {
try? FileManager.default.removeItem(at: sessionDirectories.cacheDirectory)
}
sessionDirectory = .sessionsBaseDirectory.appending(component: UUID().uuidString)
sessionDirectories = .init()
}
private func userSession(for client: Client) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
switch await userSessionStore.userSession(for: client, sessionDirectory: sessionDirectory, passphrase: passphrase) {
switch await userSessionStore.userSession(for: client, sessionDirectories: sessionDirectories, passphrase: passphrase) {
case .success(let session):
return .success(session)
case .failure(let error):
@@ -28,6 +28,7 @@ enum EncryptionAuthenticity: Hashable {
case unknownDevice(color: Color)
case unsignedDevice(color: Color)
case unverifiedIdentity(color: Color)
case previouslyVerified(color: Color)
case sentInClear(color: Color)
var message: String {
@@ -40,6 +41,8 @@ enum EncryptionAuthenticity: Hashable {
L10n.eventShieldReasonUnsignedDevice
case .unverifiedIdentity:
L10n.eventShieldReasonUnverifiedIdentity
case .previouslyVerified:
L10n.eventShieldReasonPreviouslyVerified
case .sentInClear:
L10n.eventShieldReasonSentInClear
}
@@ -51,6 +54,7 @@ enum EncryptionAuthenticity: Hashable {
.unknownDevice(let color),
.unsignedDevice(let color),
.unverifiedIdentity(let color),
.previouslyVerified(let color),
.sentInClear(let color):
color
}
@@ -59,7 +63,7 @@ enum EncryptionAuthenticity: Hashable {
var icon: KeyPath<CompoundIcons, Image> {
switch self {
case .notGuaranteed: \.info
case .unknownDevice, .unsignedDevice, .unverifiedIdentity: \.helpSolid
case .unknownDevice, .unsignedDevice, .unverifiedIdentity, .previouslyVerified: \.helpSolid
case .sentInClear: \.lockOff
}
}
@@ -87,6 +91,8 @@ extension EncryptionAuthenticity {
self = .unsignedDevice(color: color)
case .unverifiedIdentity:
self = .unverifiedIdentity(color: color)
case .previouslyVerified:
self = .previouslyVerified(color: color)
case .sentInClear:
self = .sentInClear(color: color)
}
@@ -68,7 +68,20 @@ enum TimelineItemProxy {
enum TimelineItemDeliveryStatus: Hashable {
case sending
case sent
case sendingFailed
case sendingFailed(SendFailureReason)
enum SendFailureReason: Hashable {
case verifiedUserHasUnsignedDevice(devices: [String: [String]])
case verifiedUserChangedIdentity(users: [String])
case unknown
}
var isSendingFailed: Bool {
switch self {
case .sending, .sent: false
case .sendingFailed: true
}
}
}
/// A light wrapper around event timeline items returned from Rust.
@@ -88,11 +101,15 @@ class EventTimelineItemProxy {
switch localSendState {
case .sendingFailed(_, let isRecoverable):
return isRecoverable ? .sending : .sendingFailed
return isRecoverable ? .sending : .sendingFailed(.unknown)
case .notSentYet:
return .sending
case .sent:
return .sent
case .verifiedUserHasUnsignedDevice(devices: let devices):
return .sendingFailed(.verifiedUserHasUnsignedDevice(devices: devices))
case .verifiedUserChangedIdentity(users: let users):
return .sendingFailed(.verifiedUserChangedIdentity(users: users))
}
}()
@@ -56,7 +56,7 @@ extension EventBasedTimelineItemProtocol {
}
var hasFailedToSend: Bool {
properties.deliveryStatus == .sendingFailed
properties.deliveryStatus?.isSendingFailed == true
}
var hasFailedDecryption: Bool {
@@ -20,6 +20,7 @@ import MatrixRustSDK
struct RestorationToken: Equatable {
let session: MatrixRustSDK.Session
let sessionDirectory: URL
let cacheDirectory: URL
let passphrase: String?
let pusherNotificationClientIdentifier: String?
}
@@ -29,10 +30,22 @@ extension RestorationToken: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)
let session = try container.decode(MatrixRustSDK.Session.self, forKey: .session)
let sessionDirectory = try container.decodeIfPresent(URL.self, forKey: .sessionDirectory)
let dataDirectory = try container.decodeIfPresent(URL.self, forKey: .sessionDirectory)
let cacheDirectory = try container.decodeIfPresent(URL.self, forKey: .cacheDirectory)
let sessionDirectories = if let dataDirectory {
if let cacheDirectory {
SessionDirectories(dataDirectory: dataDirectory, cacheDirectory: cacheDirectory)
} else {
SessionDirectories(dataDirectory: dataDirectory)
}
} else {
SessionDirectories(userID: session.userId)
}
self = try .init(session: session,
sessionDirectory: sessionDirectory ?? .legacySessionDirectory(for: session.userId),
sessionDirectory: sessionDirectories.dataDirectory,
cacheDirectory: sessionDirectories.cacheDirectory,
passphrase: container.decodeIfPresent(String.self, forKey: .passphrase),
pusherNotificationClientIdentifier: container.decodeIfPresent(String.self, forKey: .pusherNotificationClientIdentifier))
}
@@ -66,18 +79,3 @@ extension MatrixRustSDK.Session: Codable {
case accessToken, refreshToken, userId, deviceId, homeserverUrl, oidcData, slidingSyncProxy
}
}
// MARK: Migrations
private extension URL {
/// Gets the store directory of a legacy session that hasn't been migrated to the new token format.
///
/// This should only be used to fill in the missing value when restoring a token as older versions of
/// the SDK set the session directory for us, based on the user's ID. Newer sessions now use a UUID,
/// which is generated app side during authentication.
static func legacySessionDirectory(for userID: String) -> URL {
// Rust sanitises the user ID replacing invalid characters with an _
let sanitisedUserID = userID.replacingOccurrences(of: ":", with: "_")
return .sessionsBaseDirectory.appendingPathComponent(sanitisedUserID)
}
}
@@ -0,0 +1,61 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct SessionDirectories: Hashable, Codable {
let dataDirectory: URL
let cacheDirectory: URL
var dataPath: String { dataDirectory.path(percentEncoded: false) }
var cachePath: String { cacheDirectory.path(percentEncoded: false) }
}
extension SessionDirectories {
/// Creates a fresh set of session directories for a new user.
init() {
let sessionDirectoryName = UUID().uuidString
dataDirectory = .sessionsBaseDirectory.appending(component: sessionDirectoryName)
cacheDirectory = .cachesBaseDirectory.appending(component: sessionDirectoryName)
}
/// Creates the session directories for a user who signed in before the data directory was stored.
init(userID: String) {
dataDirectory = .legacySessionDirectory(for: userID)
cacheDirectory = .cachesBaseDirectory.appending(component: dataDirectory.lastPathComponent)
}
/// Creates the session directories for a user who has a single session directory stored without a separate caches directory.
init(dataDirectory: URL) {
self.dataDirectory = dataDirectory
cacheDirectory = .cachesBaseDirectory.appending(component: dataDirectory.lastPathComponent)
}
}
// MARK: Migrations
private extension URL {
/// Gets the store directory of a legacy session that hasn't been migrated to the new token format.
///
/// This should only be used to fill in the missing value when restoring a token as older versions of
/// the SDK set the session directory for us, based on the user's ID. Newer sessions now use a UUID,
/// which is generated app side during authentication.
static func legacySessionDirectory(for userID: String) -> URL {
// Rust sanitises the user ID replacing invalid characters with an _
let sanitisedUserID = userID.replacingOccurrences(of: ":", with: "_")
return .sessionsBaseDirectory.appendingPathComponent(sanitisedUserID)
}
}
@@ -64,20 +64,21 @@ class UserSessionStore: UserSessionStoreProtocol {
// On any restoration failure reset the token and restart
keychainController.removeRestorationTokenForUsername(credentials.userID)
deleteSessionDirectory(for: credentials)
deleteSessionDirectories(for: credentials)
return .failure(error)
}
}
func userSession(for client: Client, sessionDirectory: URL, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError> {
func userSession(for client: Client, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError> {
do {
let session = try client.session()
let userID = try client.userId()
let clientProxy = await setupProxyForClient(client)
keychainController.setRestorationToken(RestorationToken(session: session,
sessionDirectory: sessionDirectory,
sessionDirectory: sessionDirectories.dataDirectory,
cacheDirectory: sessionDirectories.cacheDirectory,
passphrase: passphrase,
pusherNotificationClientIdentifier: clientProxy.pusherNotificationClientIdentifier),
forUsername: userID)
@@ -95,7 +96,7 @@ class UserSessionStore: UserSessionStoreProtocol {
keychainController.removeRestorationTokenForUsername(userID)
if let credentials {
deleteSessionDirectory(for: credentials)
deleteSessionDirectories(for: credentials)
}
}
@@ -133,7 +134,8 @@ class UserSessionStore: UserSessionStoreProtocol {
slidingSync: appSettings.simplifiedSlidingSyncEnabled ? .simplified : .restored,
sessionDelegate: keychainController,
appHooks: appHooks)
.sessionPath(path: credentials.restorationToken.sessionDirectory.path(percentEncoded: false))
.sessionPaths(dataPath: credentials.restorationToken.sessionDirectory.path(percentEncoded: false),
cachePath: credentials.restorationToken.cacheDirectory.path(percentEncoded: false))
.username(username: credentials.userID)
.homeserverUrl(url: homeserverURL)
.passphrase(passphrase: credentials.restorationToken.passphrase)
@@ -156,22 +158,36 @@ class UserSessionStore: UserSessionStoreProtocol {
appSettings: appSettings)
}
private func deleteSessionDirectory(for credentials: KeychainCredentials) {
private func deleteSessionDirectories(for credentials: KeychainCredentials) {
do {
try FileManager.default.removeItem(at: credentials.restorationToken.sessionDirectory)
} catch {
MXLog.failure("Failed deleting the session data: \(error)")
}
do {
try FileManager.default.removeItem(at: credentials.restorationToken.cacheDirectory)
} catch {
MXLog.failure("Failed deleting the session caches: \(error)")
}
}
private func deleteCaches(for credentials: KeychainCredentials) {
do {
let sessionDirectoryContents = try FileManager.default.contentsOfDirectory(at: credentials.restorationToken.sessionDirectory, includingPropertiesForKeys: nil)
for url in sessionDirectoryContents where url.path.contains(matrixSDKStateKey) {
try FileManager.default.removeItem(at: url)
}
try deleteContentsOfDirectory(at: credentials.restorationToken.sessionDirectory)
} catch {
MXLog.failure("Failed clearing caches: \(error)")
MXLog.failure("Failed clearing state store: \(error)")
}
do {
try deleteContentsOfDirectory(at: credentials.restorationToken.cacheDirectory)
} catch {
MXLog.failure("Failed clearing event cache store: \(error)")
}
}
private func deleteContentsOfDirectory(at url: URL) throws {
let sessionDirectoryContents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
for url in sessionDirectoryContents where url.path.contains(matrixSDKStateKey) {
try FileManager.default.removeItem(at: url)
}
}
}
@@ -40,7 +40,7 @@ protocol UserSessionStoreProtocol {
func restoreUserSession() async -> Result<UserSessionProtocol, UserSessionStoreError>
/// Creates a user session for a new client from the SDK along with the passphrase used for the data stores.
func userSession(for client: Client, sessionDirectory: URL, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError>
func userSession(for client: Client, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError>
/// Logs out of the specified session.
func logout(userSession: UserSessionProtocol)