convert DecryptionFailureTracker to swift + tests

This commit is contained in:
Valere
2024-03-29 11:03:28 +01:00
parent 3253271949
commit d045a32b74
9 changed files with 479 additions and 255 deletions

View File

@@ -213,6 +213,38 @@ import AnalyticsEvents
}
}
@objc
protocol E2EAnalytics {
func trackE2EEError(_ reason: DecryptionFailureReason, context: String)
}
@objc extension Analytics: E2EAnalytics {
/// Track an E2EE error that occurred
/// - Parameters:
/// - reason: The error that occurred.
/// - context: Additional context of the error that occured
func trackE2EEError(_ reason: DecryptionFailureReason, context: String) {
let event = AnalyticsEvent.Error(
context: context,
cryptoModule: .Rust,
cryptoSDK: AnalyticsEvent.Error.CryptoSDK.Rust,
domain: .E2EE,
// XXX not yet supported.
eventLocalAgeMillis: nil,
isFederated: nil,
isMatrixDotOrg: nil,
name: reason.errorName,
timeToDecryptMillis: nil,
userTrustsOwnIdentity: nil,
wasVisibleToUser: nil
)
capture(event: event)
}
}
// MARK: - Public tracking methods
// The following methods are exposed for compatibility with Objective-C as
// the `capture` method and the generated events cannot be bridged from Swift.
@@ -266,28 +298,7 @@ extension Analytics {
func trackInteraction(_ uiElement: AnalyticsUIElement) {
trackInteraction(uiElement, interactionType: .Touch, index: nil)
}
/// Track an E2EE error that occurred
/// - Parameters:
/// - reason: The error that occurred.
/// - context: Additional context of the error that occured
func trackE2EEError(_ reason: DecryptionFailureReason, context: String) {
let event = AnalyticsEvent.Error(
context: context,
cryptoModule: .Rust,
cryptoSDK: .Rust,
domain: .E2EE,
// XXX not yet supported.
eventLocalAgeMillis: nil,
isFederated: nil,
isMatrixDotOrg: nil,
name: reason.errorName,
timeToDecryptMillis: nil,
userTrustsOwnIdentity: nil,
wasVisibleToUser: nil
)
capture(event: event)
}
/// Track when a user becomes unauthenticated without pressing the `sign out` button.
/// - Parameters:

View File

@@ -38,15 +38,16 @@ import AnalyticsEvents
/// The id of the event that was unabled to decrypt.
let failedEventId: String
/// The time the failure has been reported.
let ts: TimeInterval = Date().timeIntervalSince1970
let ts: TimeInterval
/// Decryption failure reason.
let reason: DecryptionFailureReason
/// Additional context of failure
let context: String
init(failedEventId: String, reason: DecryptionFailureReason, context: String) {
init(failedEventId: String, reason: DecryptionFailureReason, context: String, ts: TimeInterval) {
self.failedEventId = failedEventId
self.reason = reason
self.context = context
self.ts = ts
}
}

View File

@@ -1,55 +0,0 @@
/*
Copyright 2018 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/Foundation.h>
@class DecryptionFailureTracker;
@class Analytics;
@import MatrixSDK;
@interface DecryptionFailureTracker : NSObject
/**
Returns the shared tracker.
@return the shared tracker.
*/
+ (instancetype)sharedInstance;
/**
The delegate object to receive analytics events.
*/
@property (nonatomic, weak) Analytics *delegate;
/**
Report an event unable to decrypt.
This error can be momentary. The DecryptionFailureTracker will check if it gets
fixed. Else, it will generate a failure (@see `trackFailures`).
@param event the event.
@param roomState the room state when the event was received.
@param userId my user id.
*/
- (void)reportUnableToDecryptErrorForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState myUser:(NSString*)userId;
/**
Flush current data.
*/
- (void)dispatch;
@end

View File

@@ -1,174 +0,0 @@
/*
Copyright 2018 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 "DecryptionFailureTracker.h"
#import "GeneratedInterface-Swift.h"
// Call `checkFailures` every `CHECK_INTERVAL`
#define CHECK_INTERVAL 2
// Give events a chance to be decrypted by waiting `GRACE_PERIOD` before counting
// and reporting them as failures
#define GRACE_PERIOD 4
// E2E failures analytics category.
NSString *const kDecryptionFailureTrackerAnalyticsCategory = @"e2e.failure";
@interface DecryptionFailureTracker()
{
// Reported failures
// Every `CHECK_INTERVAL`, this list is checked for failures that happened
// more than`GRACE_PERIOD` ago. Those that did are reported to the delegate.
NSMutableDictionary<NSString* /* eventId */, DecryptionFailure*> *reportedFailures;
// Event ids of failures that were tracked previously
NSMutableSet<NSString*> *trackedEvents;
// Timer for periodic check
NSTimer *checkFailuresTimer;
}
@end
@implementation DecryptionFailureTracker
+ (instancetype)sharedInstance
{
static DecryptionFailureTracker *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[DecryptionFailureTracker alloc] init];
});
return sharedInstance;
}
- (instancetype)init
{
self = [super init];
if (self)
{
reportedFailures = [NSMutableDictionary dictionary];
trackedEvents = [NSMutableSet set];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidDecrypt:) name:kMXEventDidDecryptNotification object:nil];
checkFailuresTimer = [NSTimer scheduledTimerWithTimeInterval:CHECK_INTERVAL
target:self
selector:@selector(checkFailures)
userInfo:nil
repeats:YES];
}
return self;
}
- (void)reportUnableToDecryptErrorForEvent:(MXEvent *)event withRoomState:(MXRoomState *)roomState myUser:(NSString *)userId
{
if (reportedFailures[event.eventId] || [trackedEvents containsObject:event.eventId])
{
return;
}
// Filter out "expected" UTDs
// We cannot decrypt messages sent before the user joined the room
MXRoomMember *myUser = [roomState.members memberWithUserId:userId];
if (!myUser || myUser.membership != MXMembershipJoin)
{
return;
}
NSString *failedEventId = event.eventId;
DecryptionFailureReason reason;
// Categorise the error
switch (event.decryptionError.code)
{
case MXDecryptingErrorUnknownInboundSessionIdCode:
reason = DecryptionFailureReasonOlmKeysNotSent;
break;
case MXDecryptingErrorOlmCode:
reason = DecryptionFailureReasonOlmIndexError;
break;
default:
// All other error codes will be tracked as `OlmUnspecifiedError` and will include `context` containing
// the actual error code and localized description
reason = DecryptionFailureReasonUnspecified;
break;
}
NSString *context = [NSString stringWithFormat:@"code: %ld, description: %@", event.decryptionError.code, event.decryptionError.localizedDescription];
reportedFailures[event.eventId] = [[DecryptionFailure alloc] initWithFailedEventId:failedEventId
reason:reason
context:context];
}
- (void)dispatch
{
[self checkFailures];
}
#pragma mark - Private methods
/**
Mark reported failures that occured before tsNow - GRACE_PERIOD as failures that should be
tracked.
*/
- (void)checkFailures
{
if (!_delegate)
{
return;
}
NSTimeInterval tsNow = [NSDate date].timeIntervalSince1970;
NSMutableArray *failuresToTrack = [NSMutableArray array];
for (DecryptionFailure *reportedFailure in reportedFailures.allValues)
{
if (reportedFailure.ts < tsNow - GRACE_PERIOD)
{
[failuresToTrack addObject:reportedFailure];
[reportedFailures removeObjectForKey:reportedFailure.failedEventId];
[trackedEvents addObject:reportedFailure.failedEventId];
}
}
if (failuresToTrack.count)
{
// Sort failures by error reason
NSMutableDictionary<NSNumber*, NSNumber*> *failuresCounts = [NSMutableDictionary dictionary];
for (DecryptionFailure *failure in failuresToTrack)
{
failuresCounts[@(failure.reason)] = @(failuresCounts[@(failure.reason)].unsignedIntegerValue + 1);
[self.delegate trackE2EEError:failure.reason context:failure.context];
}
MXLogDebug(@"[DecryptionFailureTracker] trackFailures: %@", failuresCounts);
}
}
- (void)eventDidDecrypt:(NSNotification *)notif
{
// Could be an event in the reportedFailures, remove it
MXEvent *event = notif.object;
[reportedFailures removeObjectForKey:event.eventId];
}
@end

View File

@@ -0,0 +1,138 @@
//
// 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
// Protocol to get the current time. Used for easy testing
protocol TimeProvider {
func nowTs() -> TimeInterval
}
class DefaultTimeProvider: TimeProvider {
func nowTs() -> TimeInterval {
return Date.now.timeIntervalSince1970
}
}
@objc
class DecryptionFailureTracker: NSObject {
let GRACE_PERIOD: TimeInterval = 4
// Call `checkFailures` every `CHECK_INTERVAL`
let CHECK_INTERVAL: TimeInterval = 2
@objc weak var delegate: E2EAnalytics?
// Reported failures
var reportedFailures = [String /* eventId */: DecryptionFailure]()
// Event ids of failures that were tracked previously
var trackedEvents = Set<String>()
var checkFailuresTimer: Timer?
@objc static let sharedInstance = DecryptionFailureTracker()
var timeProvider: TimeProvider = DefaultTimeProvider()
override init() {
super.init()
NotificationCenter.default.addObserver(self,
selector: #selector(eventDidDecrypt(_:)),
name: .mxEventDidDecrypt,
object: nil)
Timer.scheduledTimer(withTimeInterval: CHECK_INTERVAL, repeats: true) { [weak self] _ in
self?.checkFailures()
}
}
@objc
func reportUnableToDecryptError(forEvent event: MXEvent, withRoomState roomState: MXRoomState, myUser userId: String) {
if reportedFailures[event.eventId] != nil || trackedEvents.contains(event.eventId) {
return
}
// Filter out "expected" UTDs
// We cannot decrypt messages sent before the user joined the room
guard let myUser = roomState.members.member(withUserId: userId) else { return }
if myUser.membership != MXMembership.join {
return
}
guard let failedEventId = event.eventId else { return }
guard let error = event.decryptionError as? NSError else { return }
var reason = DecryptionFailureReason.unspecified
if error.code == MXDecryptingErrorUnknownInboundSessionIdCode.rawValue {
reason = DecryptionFailureReason.olmKeysNotSent
} else if error.code == MXDecryptingErrorOlmCode.rawValue {
reason = DecryptionFailureReason.olmIndexError
}
let context = String(format: "code: %ld, description: %@", error.code, event.decryptionError.localizedDescription)
reportedFailures[failedEventId] = DecryptionFailure(failedEventId: failedEventId, reason: reason, context: context, ts: self.timeProvider.nowTs())
}
@objc
func dispatch() {
self.checkFailures()
}
@objc
func eventDidDecrypt(_ notification: Notification) {
guard let event = notification.object as? MXEvent else { return }
// Could be an event in the reportedFailures, remove it
reportedFailures.removeValue(forKey: event.eventId)
}
/**
Mark reported failures that occured before tsNow - GRACE_PERIOD as failures that should be
tracked.
*/
@objc
func checkFailures() {
guard let delegate = self.delegate else {return}
let tsNow = self.timeProvider.nowTs()
var failuresToCheck = [DecryptionFailure]()
for reportedFailure in self.reportedFailures.values {
let ellapsed = tsNow - reportedFailure.ts
if ellapsed > GRACE_PERIOD {
failuresToCheck.append(reportedFailure)
reportedFailures.removeValue(forKey: reportedFailure.failedEventId)
trackedEvents.insert(reportedFailure.failedEventId)
}
}
for failure in failuresToCheck {
delegate.trackE2EEError(failure.reason, context: failure.context)
}
}
}

View File

@@ -33,7 +33,6 @@
#import "ContactDetailsViewController.h"
#import "BugReportViewController.h"
#import "DecryptionFailureTracker.h"
#import "Tools.h"
#import "WidgetManager.h"

View File

@@ -23,7 +23,6 @@
#import "WidgetManager.h"
#import "MXDecryptionResult.h"
#import "DecryptionFailureTracker.h"
#import <MatrixSDK/MatrixSDK.h>

View File

@@ -0,0 +1,196 @@
//
// 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
import XCTest
@testable import Element
class DecryptionFailureTrackerTests: XCTestCase {
class TimeShifter: TimeProvider {
var timestamp = TimeInterval(0)
func nowTs() -> TimeInterval {
return timestamp
}
}
class AnalyticsDelegate : E2EAnalytics {
var reportedFailure: Element.DecryptionFailureReason?;
func trackE2EEError(_ reason: Element.DecryptionFailureReason, context: String) {
print("Error Tracked: ", reason)
reportedFailure = reason
}
}
let timeShifter = TimeShifter()
func test_grace_period() {
let myUser = "test@example.com";
let decryptionFailureTracker = DecryptionFailureTracker();
decryptionFailureTracker.timeProvider = timeShifter;
let testDelegate = AnalyticsDelegate();
decryptionFailureTracker.delegate = testDelegate;
timeShifter.timestamp = TimeInterval(0)
let fakeEvent = FakeEvent(id: "$0000");
fakeEvent.decryptionError = NSError(domain: MXDecryptingErrorDomain, code: Int(MXDecryptingErrorUnknownInboundSessionIdCode.rawValue))
let fakeRoomState = FakeRoomState();
fakeRoomState.mockMembers = FakeRoomMembers(joined: [myUser])
decryptionFailureTracker.reportUnableToDecryptError(forEvent: fakeEvent, withRoomState: fakeRoomState, myUser: myUser);
timeShifter.timestamp = TimeInterval(2)
decryptionFailureTracker.checkFailures();
XCTAssertNil(testDelegate.reportedFailure);
// Pass the grace period
timeShifter.timestamp = TimeInterval(5)
decryptionFailureTracker.checkFailures();
XCTAssertEqual(testDelegate.reportedFailure, DecryptionFailureReason.olmKeysNotSent);
}
func test_do_not_double_report() {
let myUser = "test@example.com";
let decryptionFailureTracker = DecryptionFailureTracker();
decryptionFailureTracker.timeProvider = timeShifter;
let testDelegate = AnalyticsDelegate();
decryptionFailureTracker.delegate = testDelegate;
timeShifter.timestamp = TimeInterval(0)
let fakeEvent = FakeEvent(id: "$0000");
fakeEvent.decryptionError = NSError(domain: MXDecryptingErrorDomain, code: Int(MXDecryptingErrorUnknownInboundSessionIdCode.rawValue))
let fakeRoomState = FakeRoomState();
fakeRoomState.mockMembers = FakeRoomMembers(joined: [myUser])
decryptionFailureTracker.reportUnableToDecryptError(forEvent: fakeEvent, withRoomState: fakeRoomState, myUser: myUser);
// Pass the grace period
timeShifter.timestamp = TimeInterval(5)
decryptionFailureTracker.checkFailures();
XCTAssertEqual(testDelegate.reportedFailure, DecryptionFailureReason.olmKeysNotSent);
// Try to report again the same event
testDelegate.reportedFailure = nil
decryptionFailureTracker.reportUnableToDecryptError(forEvent: fakeEvent, withRoomState: fakeRoomState, myUser: myUser);
// Pass the grace period
timeShifter.timestamp = TimeInterval(10)
decryptionFailureTracker.checkFailures();
XCTAssertNil(testDelegate.reportedFailure);
}
func test_ignore_not_member() {
let myUser = "test@example.com";
let decryptionFailureTracker = DecryptionFailureTracker();
decryptionFailureTracker.timeProvider = timeShifter;
let testDelegate = AnalyticsDelegate();
decryptionFailureTracker.delegate = testDelegate;
timeShifter.timestamp = TimeInterval(0)
let fakeEvent = FakeEvent(id: "$0000");
fakeEvent.decryptionError = NSError(domain: MXDecryptingErrorDomain, code: Int(MXDecryptingErrorUnknownInboundSessionIdCode.rawValue))
let fakeRoomState = FakeRoomState();
let fakeMembers = FakeRoomMembers()
fakeMembers.mockMembers[myUser] = MXMembership.ban
fakeRoomState.mockMembers = fakeMembers
decryptionFailureTracker.reportUnableToDecryptError(forEvent: fakeEvent, withRoomState: fakeRoomState, myUser: myUser);
// Pass the grace period
timeShifter.timestamp = TimeInterval(5)
decryptionFailureTracker.checkFailures();
XCTAssertNil(testDelegate.reportedFailure);
}
func test_notification_center() {
let myUser = "test@example.com";
let decryptionFailureTracker = DecryptionFailureTracker();
decryptionFailureTracker.timeProvider = timeShifter;
let testDelegate = AnalyticsDelegate();
decryptionFailureTracker.delegate = testDelegate;
timeShifter.timestamp = TimeInterval(0)
let fakeEvent = FakeEvent(id: "$0000");
fakeEvent.decryptionError = NSError(domain: MXDecryptingErrorDomain, code: Int(MXDecryptingErrorUnknownInboundSessionIdCode.rawValue))
let fakeRoomState = FakeRoomState();
fakeRoomState.mockMembers = FakeRoomMembers(joined: [myUser])
decryptionFailureTracker.reportUnableToDecryptError(forEvent: fakeEvent, withRoomState: fakeRoomState, myUser: myUser);
// Shift time below GRACE_PERIOD
timeShifter.timestamp = TimeInterval(2)
// Simulate event gets decrypted
NotificationCenter.default.post(name: .mxEventDidDecrypt, object: fakeEvent)
// Shift time after GRACE_PERIOD
timeShifter.timestamp = TimeInterval(6)
decryptionFailureTracker.checkFailures();
// Event should have been graced
XCTAssertNil(testDelegate.reportedFailure);
}
}

109
RiotTests/FakeUtils.swift Normal file
View File

@@ -0,0 +1,109 @@
//
// 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
class FakeEvent: MXEvent {
var mockEventId: String;
var mockSender: String!;
var mockDecryptionError: Error?
init(id: String) {
mockEventId = id
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var sender: String! {
get { return mockSender }
set { mockSender = newValue }
}
override var eventId: String! {
get { return mockEventId }
set { mockEventId = newValue }
}
override var decryptionError: Error? {
get { return mockDecryptionError }
set { mockDecryptionError = newValue }
}
}
class FakeRoomState: MXRoomState {
var mockMembers: MXRoomMembers?
override var members: MXRoomMembers? {
get { return mockMembers }
set { mockMembers = newValue }
}
}
class FakeRoomMember: MXRoomMember {
var mockMembership: MXMembership = MXMembership.join
var mockUserId: String!
var mockMembers: MXRoomMembers? = FakeRoomMembers()
init(mockUserId: String!) {
self.mockUserId = mockUserId
super.init()
}
override var membership: MXMembership {
get { return mockMembership }
set { mockMembership = newValue }
}
override var userId: String!{
get { return mockUserId }
set { mockUserId = newValue }
}
}
class FakeRoomMembers: MXRoomMembers {
var mockMembers = [String : MXMembership]()
init(joined: [String] = [String]()) {
for userId in joined {
self.mockMembers[userId] = MXMembership.join
}
super.init()
}
override func member(withUserId userId: String!) -> MXRoomMember? {
let membership = mockMembers[userId]
if membership != nil {
let mockMember = FakeRoomMember(mockUserId: userId)
mockMember.mockMembership = membership!
return mockMember
} else {
return nil
}
}
}