Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/adr/0035-iOS-NSE-issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 35. Fix iOS NSE Decryption Issue

Date: 2026-03-06

## Status

Accepted

## Context

iOS background notification only shows as "You have 1 encrypted message", whether the message is encrypted or not.

### Problem 1: Session stopped syncing

- Before Matrix Dart SDK migration, We was using FlutterHiveCollectionsDatabase as database, and it was using \_updateIOSKeychainSharingRestoreToken to sync session to iOS keychain.
- After Matrix Dart SDK migration, We are using MatrixSdkDatabase as database, and FlutterHiveCollectionsDatabase now only exist to migrate old data, so \_updateIOSKeychainSharingRestoreToken is never called.

### Problem 2: Twake Chat was never confirmed to decrypt notification successfully

- From https://github.com/linagora/twake-on-matrix/issues/1049, only unencrypted notification was successfully shown.
- Matrix Rust SDK implementation was 100% copied from Element X iOS repo to NSE. However, Element X iOS also use Matrix Rust SDK in their main app, so the database where keys live is the same.
- We never properly set up Matrix Rust SDK database in NSE, so even though Matrix Rust SDK has the ability to decrypt encrypted messages, it doesn't have the keys to do so.

## Solution

### 1. Resync the session

To ensure the Notification Service Extension (NSE) has the latest session context, we actively synchronize the session to the iOS keychain.

- Added `KeychainSharingManager.saveSession()` to save `accessToken`, `userId`, `homeserverUrl`, and `deviceId` to the shared secure storage.
- Added a listener in `BackgroundPush._setupKeychainSyncListener` to intercept `client.onSync` events and save the session to the keychain.
- Added `_syncKeychainForClient` in `ClientManager` to save the session whenever a client is initialized.

### 2. Push the recovery key from Flutter side to iOS side

In order to allow the Matrix Rust SDK within the NSE to decrypt messages, it must have access to the recovery key.

- Implemented `BackgroundPush._syncRecoveryKeyToKeychain` to fetch the recovery words via `GetRecoveryWordsInteractor` and push them to the iOS keychain using `KeychainSharingManager.saveRecoveryKey`.
- Added logic to `SettingsController._logoutActions` to delete this recovery key from the keychain upon logout.

## Consequences

### Benefits

- **Successful Decryption**: The NSE now has the necessary session details and keys to instantiate the Matrix Rust SDK and decrypt incoming messages in the background.
- **Improved UX**: Users will see the actual message content rather than a generic "You have 1 encrypted message" notification.

### Risks and Mitigations

- **Security**: Storing the recovery key in the shared keychain increases exposure slightly, but it is necessary for background decryption and is limited to the designated keychain access group.
- **State consistency**: Session tokens and recovery keys are correctly cleared upon user logout to prevent unauthorized background process access.
14 changes: 13 additions & 1 deletion ios/NSE/KeychainController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,24 @@ class KeychainController: KeychainControllerProtocol {
}
}

// MARK: - Recovery Key

func recoveryKey(forUsername username: String) -> String? {
let keychainKey = "ssss_recovery_\(username)"
do {
return try keychain.getString(keychainKey)
} catch {
MXLog.error("Failed retrieving SSSS recovery key: \(error)")
return nil
}
}

// MARK: - ClientSessionDelegate

func retrieveSessionFromKeychain(userId: String) throws -> Session {
MXLog.info("Retrieving an updated Session from the keychain.")
guard let session = restorationTokenForUsername(userId)?.session else {
throw ClientError.Generic(msg: "Failed to find RestorationToken in the Keychain.")
throw ClientError.Generic(msg: "Failed to find RestorationToken in the Keychain.", details: nil)
}
return session
}
Expand Down
11 changes: 6 additions & 5 deletions ios/NSE/Logging/MXLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//

import Foundation
import os
import MatrixRustSDK

/**
Expand Down Expand Up @@ -59,16 +60,14 @@ enum MXLog {
return
}

setupTracing(configuration: .init(logLevel: logLevel), otlpConfiguration: otlpConfiguration)

if let target {
self.target = target
MXLogger.setSubLogName(target)
} else {
self.target = Constants.target
}

rootSpan = Span(file: #file, line: #line, level: .info, target: self.target, name: "root")
rootSpan = Span(file: #file, line: UInt32(#line), level: .info, target: self.target, name: "root", bridgeTraceId: nil)

rootSpan.enter()

Expand Down Expand Up @@ -174,7 +173,7 @@ enum MXLog {
rootSpan.enter()
}

return Span(file: file, line: UInt32(line), level: level, target: target, name: name)
return Span(file: file, line: UInt32(line), level: level, target: target, name: name, bridgeTraceId: nil)
}

private static func log(_ message: Any,
Expand All @@ -192,6 +191,8 @@ enum MXLog {
rootSpan.enter()
}

logEvent(file: (file as NSString).lastPathComponent, line: UInt32(line), level: level, target: target, message: "\(message)")
let messageString = "\(message)"
logEvent(file: (file as NSString).lastPathComponent, line: UInt32(line), level: level, target: target, message: messageString)
os_log("MXLog: %{public}@", messageString)
}
}
16 changes: 0 additions & 16 deletions ios/NSE/Logging/RustTracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,3 @@ struct TracingConfiguration {
filter = components.joined(separator: ",")
}
}

func setupTracing(configuration: TracingConfiguration, otlpConfiguration: OTLPConfiguration?) {
if let otlpConfiguration {
setupOtlpTracing(config: .init(clientName: "ElementX-iOS",
user: otlpConfiguration.username,
password: otlpConfiguration.password,
otlpEndpoint: otlpConfiguration.url,
filter: configuration.filter,
writeToStdoutOrSystem: true,
writeToFiles: nil))
} else {
setupTracing(config: .init(filter: configuration.filter,
writeToStdoutOrSystem: true,
writeToFiles: nil))
}
}
17 changes: 15 additions & 2 deletions ios/NSE/Provider/MediaFileHandleProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ class MediaFileHandleProxy {

/// The media file's location on disk.
var url: URL {
URL(filePath: handle.path())
do {
return URL(filePath: try handle.path())
} catch {
MXLog.error("Failed to get media file path: \(error)")
return URL(filePath: "")
}
}
}

Expand All @@ -59,9 +64,17 @@ extension MediaFileHandleProxy: Hashable {
/// An unmanaged file handle that can be created direct from a URL.
///
/// This type allows for mocking but doesn't provide the automatic clean-up mechanism provided by the SDK.
private struct UnmanagedMediaFileHandle: MediaFileHandleProtocol {
private final class UnmanagedMediaFileHandle: MediaFileHandleProtocol {
func persist(path: String) throws -> Bool {
false
}

let url: URL

init(url: URL) {
self.url = url
}

func path() -> String {
url.path()
}
Expand Down
34 changes: 17 additions & 17 deletions ios/NSE/Provider/MediaLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ private final class MediaRequest {
var continuations: [CheckedContinuation<Data, Error>] = []
}

private enum MediaRequestKey: Hashable {
case content(MediaSourceProxy)
case thumbnail(MediaSourceProxy, width: UInt, height: UInt)
}

actor MediaLoader: MediaLoaderProtocol {
private let client: ClientProtocol
private let clientQueue: DispatchQueue
private var ongoingRequests = [MediaSourceProxy: MediaRequest]()
private var ongoingRequests = [MediaRequestKey: MediaRequest]()

init(client: ClientProtocol,
clientQueue: DispatchQueue = .global()) {
Expand All @@ -35,46 +40,41 @@ actor MediaLoader: MediaLoaderProtocol {
}

func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
try await enqueueLoadMediaRequest(forSource: source) {
try self.client.getMediaContent(mediaSource: source.underlyingSource)
try await enqueueLoadMediaRequest(forKey: .content(source)) {
try await self.client.getMediaContent(mediaSource: source.underlyingSource)
}
}

func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
try await enqueueLoadMediaRequest(forSource: source) {
try self.client.getMediaThumbnail(mediaSource: source.underlyingSource, width: UInt64(width), height: UInt64(height))
try await enqueueLoadMediaRequest(forKey: .thumbnail(source, width: width, height: height)) {
try await self.client.getMediaThumbnail(mediaSource: source.underlyingSource, width: UInt64(width), height: UInt64(height))
}
}

func loadMediaFileForSource(_ source: MediaSourceProxy, body: String?) async throws -> MediaFileHandleProxy {
let result = try await Task.dispatch(on: clientQueue) {
try self.client.getMediaFile(mediaSource: source.underlyingSource, body: body, mimeType: source.mimeType ?? "application/octet-stream", tempDir: nil)
}
func loadMediaFileForSource(_ source: MediaSourceProxy, body filename: String?) async throws -> MediaFileHandleProxy {
let result = try await self.client.getMediaFile(mediaSource: source.underlyingSource, filename: filename, mimeType: source.mimeType ?? "application/octet-stream", useCache: true, tempDir: nil)

return MediaFileHandleProxy(handle: result)
}

// MARK: - Private

private func enqueueLoadMediaRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> [UInt8]) async throws -> Data {
if let ongoingRequest = ongoingRequests[source] {
private func enqueueLoadMediaRequest(forKey key: MediaRequestKey, operation: @escaping () async throws -> Data) async throws -> Data {
if let ongoingRequest = ongoingRequests[key] {
return try await withCheckedThrowingContinuation { continuation in
ongoingRequest.continuations.append(continuation)
}
}

let ongoingRequest = MediaRequest()
ongoingRequests[source] = ongoingRequest
ongoingRequests[key] = ongoingRequest

defer {
ongoingRequests[source] = nil
ongoingRequests[key] = nil
}

do {
let result = try await Task.dispatch(on: clientQueue) {
let bytes = try operation()
return Data(bytes: bytes, count: bytes.count)
}
let result = try await operation()

ongoingRequest.continuations.forEach { $0.resume(returning: result) }

Expand Down
5 changes: 3 additions & 2 deletions ios/NSE/Provider/MediaSourceProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ struct MediaSourceProxy: Hashable {
self.mimeType = mimeType
}

init(url: URL, mimeType: String?) {
underlyingSource = mediaSourceFromUrl(url: url.absoluteString)
init?(url: URL, mimeType: String?) {
guard let source = try? MediaSource.fromUrl(url: url.absoluteString) else { return nil }
underlyingSource = source
self.url = URL(string: underlyingSource.url())
self.mimeType = mimeType
}
Expand Down
6 changes: 3 additions & 3 deletions ios/NSE/Proxy/NotificationItemProxyProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ extension NotificationItemProxyProtocol {
case .invite, .none:
return false
case .timeline(let event):
switch try? event.eventType() {
switch try? event.content() {
case .state, .none:
return false
case let .messageLike(content):
case .messageLike(content: let content):
switch content {
case let .roomMessage(messageType, _):
case .roomMessage(messageType: let messageType, inReplyToEventId: _):
switch messageType {
case .image, .video, .audio:
return true
Expand Down
3 changes: 1 addition & 2 deletions ios/NSE/RestorationToken.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ extension MatrixRustSDK.Session: Codable {
deviceId: container.decode(String.self, forKey: .deviceId),
homeserverUrl: container.decode(String.self, forKey: .homeserverUrl),
oidcData: container.decodeIfPresent(String.self, forKey: .oidcData),
slidingSyncProxy: container.decodeIfPresent(String.self, forKey: .slidingSyncProxy))
slidingSyncVersion: .native)
}

public func encode(to encoder: Encoder) throws {
Expand All @@ -54,7 +54,6 @@ extension MatrixRustSDK.Session: Codable {
try container.encode(deviceId, forKey: .deviceId)
try container.encode(homeserverUrl, forKey: .homeserverUrl)
try container.encode(oidcData, forKey: .oidcData)
try container.encode(slidingSyncProxy, forKey: .slidingSyncProxy)
}

enum CodingKeys: String, CodingKey {
Expand Down
4 changes: 4 additions & 0 deletions ios/NSE/RoomMessageEventStringBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ struct RoomMessageEventStringBuilder {
} else {
message = content.body
}
case .other(msgtype: let msgtype, body: let body):
message = body
case .gallery(content: let content):
message = content.body
}

if prefixWithSenderName {
Expand Down
6 changes: 3 additions & 3 deletions ios/NSE/Sources/NotificationContentBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ struct NotificationContentBuilder {
case .invite:
return try await processInvited(notificationItem: notificationItem, mediaProvider: mediaProvider)
case .timeline(let event):
switch try? event.eventType() {
case let .messageLike(content):
switch try? event.content() {
case .messageLike(content: let content):
switch content {
case .roomMessage(let messageType, _):
case .roomMessage(messageType: let messageType, inReplyToEventId: _):
return try await processRoomMessage(notificationItem: notificationItem, messageType: messageType, mediaProvider: mediaProvider)
default:
return processEmpty(notificationItem: notificationItem)
Expand Down
5 changes: 3 additions & 2 deletions ios/NSE/Sources/NotificationServiceExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
MXLog.info("\(tag) run with roomId: \(roomId), eventId: \(eventId)")

do {
let userSession = try NSEUserSession(credentials: credentials, clientSessionDelegate: keychainController)
let recoveryKey = keychainController.recoveryKey(forUsername: credentials.userID)
let userSession = try await NSEUserSession(credentials: credentials, roomID: roomId, clientSessionDelegate: keychainController, recoveryKey: recoveryKey)
self.userSession = userSession

guard let itemProxy = await userSession.notificationItemProxy(roomID: roomId, eventID: eventId) else {
Expand Down Expand Up @@ -128,7 +129,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
private func discard() {
MXLog.info("\(tag) discard")

handler?(UNMutableNotificationContent())
handler?(modifiedContent ?? UNMutableNotificationContent())
cleanUp()
}

Expand Down
Loading
Loading