Skip to content

Comments

feat: re-establish EAR support - WPB-23473#4299

Open
David-Henner wants to merge 27 commits intorelease/cycle-4.16from
feat/ear-support-without-new-sync
Open

feat: re-establish EAR support - WPB-23473#4299
David-Henner wants to merge 27 commits intorelease/cycle-4.16from
feat/ear-support-without-new-sync

Conversation

@David-Henner
Copy link
Contributor

@David-Henner David-Henner commented Feb 11, 2026

TaskWPB-23473 [iOS] Refactor database key access for message encryption & migrations

Issue

This PR re-establishes Encryption at Rest after it was broken by the introduction of dynamic background contexts. The implementation decouples encryption logic from NSManagedObjectContext and centralizes database key management through a service-based architecture.

The previous EAR implementation had critical issues:

  1. Missing keys in dynamic contexts - Ad-hoc background contexts created via CoreDataStack.newBackgroundContext() lacked the database key, causing decryption errors and disappearing messages
  2. Migration failures - EAR enable/disable operations create temporary contexts that were missing the database key, causing migration failure
  3. Initialization deadlock - Setting the database key on the contexts was causing a deadlock on the view context.

Changes

  • Created EARMessageEncryptionService for database key management and centralized encryption
  • Created EARMigrator for migrations when enabling / disabling EAR
  • Made EARService initialization async
  • Split unit tests from integration tests with EARServiceTests and EARServiceIntegrationTests
  • Added doc comments

Checklist

  • Title contains a reference JIRA issue number like [WPB-XXX].
  • Description is filled and free of optional paragraphs.
  • Adds/updates automated tests.

@David-Henner David-Henner force-pushed the feat/ear-support-without-new-sync branch from feb4b98 to 268f4a8 Compare February 13, 2026 09:21
@David-Henner David-Henner changed the base branch from develop to feat/ear-support February 13, 2026 09:28
@David-Henner David-Henner changed the title feat: re-establish EAR support feat: re-establish EAR support - WPB-23473 Feb 13, 2026
@David-Henner David-Henner marked this pull request as ready for review February 13, 2026 10:00
@github-actions
Copy link
Contributor

github-actions bot commented Feb 13, 2026

Test Results

    8 files    982 suites   9m 50s ⏱️
7 059 tests 7 031 ✅ 28 💤 0 ❌
7 060 runs  7 032 ✅ 28 💤 0 ❌

Results for commit 18f63c3.

♻️ This comment has been updated with latest results.

Summary: workflow run #22218036080
Allure report (download zip): html-report-28036-feat_ear-support-without-new-sync

@datadog-wireapp
Copy link

datadog-wireapp bot commented Feb 13, 2026

⚠️ Tests

Fix all issues with Cursor

⚠️ Warnings

🧪 14 Tests failed

testThatDatabaseIsLocked_AfterBackgroundTaskCompletesInTheBackground() from UnitTests.ZMUserSessionTests_EncryptionAtRest (Datadog) (Fix with Cursor)
Crash: WireSyncEngine Test Host at ZMUserSessionTests_EncryptionAtRest.setEncryptionAtRest(enabled:file:line:)
testThatDatabaseIsLocked_AfterEnteringBackground() from UnitTests.ZMUserSessionTests_EncryptionAtRest (Datadog) (Fix with Cursor)
Crash: WireSyncEngine Test Host at ZMUserSessionTests_EncryptionAtRest.setEncryptionAtRest(enabled:file:line:)
testThatDatabaseIsLocked_WhenTheCustomTimeoutHasExpiredInTheBackground() from UnitTests.ZMUserSessionTests_EncryptionAtRest (Datadog) (Fix with Cursor)
Crash: WireSyncEngine Test Host at implicit closure #1 in ZMUserSessionTests_EncryptionAtRest.testThatDatabaseIsLocked_WhenTheCustomTimeoutHasExpiredInTheBackground()
View all

ℹ️ Info

❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 27f2676 | Docs | Was this helpful? Give us feedback!

Comment on lines -294 to -387
// @SF.Storage @TSFI.FS-IOS @TSFI.Enclave-IOS @S0.1 @S0.2
// Make sure that message content is encrypted when EAR is enabled
func test_ExistingMessageContentIsEncrypted_WhenEarIsEnabled() throws {
// Given
uiMOC.encryptMessagesAtRest = false
uiMOC.databaseKey = nil

let conversation = createConversation(in: uiMOC)
try conversation.appendText(content: "Beep bloop")

let results: [ZMGenericMessageData] = try uiMOC.fetchObjects()

guard let messageData = results.first else {
XCTFail("Could not find message data.")
return
}

// Then
XCTAssertFalse(messageData.isEncrypted)
XCTAssertEqual(messageData.unencryptedContent, "Beep bloop")
XCTAssertFalse(uiMOC.encryptMessagesAtRest)

// Mock
mockKeyGeneration()

// When
XCTAssertNoThrow(try sut.enableEncryptionAtRest(context: uiMOC))

// Then migration was run
XCTAssertEqual(prepareForMigrationCalls, 1)
XCTAssertTrue(messageData.isEncrypted)
XCTAssertEqual(messageData.unencryptedContent, "Beep bloop")

// Then EAR is enabled on the context
XCTAssertTrue(uiMOC.encryptMessagesAtRest)
}

// @SF.Storage @TSFI.FS-IOS @TSFI.Enclave-IOS @S0.1 @S0.2
// Make sure that message content normalized for text search is also encrypted when EAR is enabled
func test_NormalizedMessageContentIsCleared_WhenEarIsEnabled() throws {
// Given
uiMOC.encryptMessagesAtRest = false
uiMOC.databaseKey = nil

let conversation = createConversation(in: uiMOC)
let message = try conversation.appendText(content: "Beep bloop") as! ZMMessage

// Then
XCTAssertNotNil(message.normalizedText)
XCTAssertEqual(message.normalizedText?.isEmpty, false)

// Mock
mockKeyGeneration()

// When
XCTAssertNoThrow(try sut.enableEncryptionAtRest(context: uiMOC))

// Then
XCTAssertNotNil(message.normalizedText)
XCTAssertEqual(message.normalizedText?.isEmpty, true)
XCTAssertTrue(uiMOC.encryptMessagesAtRest)
}

// @SF.Storage @TSFI.FS-IOS @TSFI.Enclave-IOS @S0.1 @S0.2
// Make sure that message content that is drafted but not send by the user yet is also encrypted
// when EAR is enabled
func test_DraftMessageContentIsEncrypted_WhenEarIsEnabled() throws {
// Given
uiMOC.encryptMessagesAtRest = false
uiMOC.databaseKey = nil

let conversation = createConversation(in: uiMOC)
conversation.draftMessage = DraftMessage(
text: "Beep bloop",
mentions: [],
quote: nil
)

// Then
XCTAssertTrue(conversation.hasDraftMessage)
XCTAssertFalse(conversation.hasEncryptedDraftMessageData)
XCTAssertEqual(conversation.unencryptedDraftMessageContent, "Beep bloop")

// Mock
mockKeyGeneration()

// When
XCTAssertNoThrow(try sut.enableEncryptionAtRest(context: uiMOC))

// Then
XCTAssertTrue(conversation.hasEncryptedDraftMessageData)
XCTAssertEqual(conversation.unencryptedDraftMessageContent, "Beep bloop")
XCTAssertTrue(uiMOC.encryptMessagesAtRest)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved these security tests to EARServiceIntegrationTests


// MARK: - Security tests

// @SF.Storage @TSFI.FS-IOS @TSFI.Enclave-IOS @S0.1 @S0.2
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to EARServiceIntegrationTests

XCTAssertEqual(keyRepository.deleteDatabaseKeyDescription_Invocations.count, 1)
}

// @SF.Storage @TSFI.UserInterface @S0.1 @S0.2
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to EARServiceIntegrationTests

import WireDataModelSupport
@testable import WireSyncEngine

// TODO: [WPB-23474] Fix the tests setup
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test class is skipped temporarily

Copy link
Collaborator

@netbe netbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a couple questions before approving

Comment on lines 183 to 189
lock.unlock()
return cached
}

defer {
lock.unlock()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: can't we just do defer { lock.unlock() from the top?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#endif

// Set the EARMessageEncryptionService on the new background context
context.performAndWait {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: what about making the method async

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition is that since this context has just been created and not returned, in no place else could there be a simultaneous performAndWait which could lead to a deadlock.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

// MARK: - Security Tests

// @SF.Storage @TSFI.FS-IOS @TSFI.Enclave-IOS @S0.1 @S0.2
func test_ItStoresAndClearsDatabaseKeyOnAllContexts() async throws {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: check the security tests is enabled the Security Tests plan

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done cbbf667

Comment on lines +396 to +397
keyRepository: EARKeyRepository(), // Real keychain access
keyEncryptor: EARKeyEncryptor(), // Real crypto
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: why do we need real objects here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're used to generate and store real keys. The test is verifying that the service is replacing keys after disabling / enabling EAR.

Copy link
Collaborator

@johnxnguyen johnxnguyen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! I'll approve when the current conversations are resolved. Also, are we able to playtest these changes as is or is there something missing?


let contextData = try context.earContextData()

context.saveOrRollback()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: a comment explaining this save would be nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH I'm not sure why it's there. I've moved this part of the code from the NSManagedObjecContext extension.

#endif

// Set the EARMessageEncryptionService on the new background context
context.performAndWait {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition is that since this context has just been created and not returned, in no place else could there be a simultaneous performAndWait which could lead to a deadlock.

@David-Henner
Copy link
Contributor Author

David-Henner commented Feb 16, 2026

Looks good! I'll approve when the current conversations are resolved. Also, are we able to playtest these changes as is or is there something missing?

@johnxnguyen besides incremental sync support coming in a separate PR, there's nothing missing and it can be playtested

do {
return try moc.encryptData(data: data)
} catch let error as NSManagedObjectContext.EncryptionError {
let service = try moc.getEarMessageEncryptionService()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getEarMessageEncryptionService() will throw if this context wasn’t wired with the EAR service. Any ad‑hoc context that skips setEARMessageEncryptionService will now fail encryption at runtime when EAR is enabled - can we enforce injection or assert here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added an assertion cbbf667

guard moc.encryptMessagesAtRest else { return (data, nonce: nil) }
return try moc.encryptData(data: data)

let service = try moc.getEarMessageEncryptionService()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: this assumes earMessageEncryptionService is injected into the context. If any context is created outside CoreDataStack (or before injection), draft encryption will throw - can we assert/guard or centralize context creation to guarantee injection?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, I added an assertion. Additionally, the context creation is centralized through CoreDataStack. Although there's nothing restricting from creating a context differently

@David-Henner David-Henner changed the base branch from feat/ear-support to release/cycle-4.16 February 19, 2026 08:54
@sonarqubecloud
Copy link

@netbe netbe added the release label Feb 20, 2026
Copy link
Collaborator

@netbe netbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me

"skippedTests" : [
"AvailabilityTests",
"EventProcessingPerformanceTests",
"SessionManagerEncryptionAtRestMigrationTests",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: why is it skipped ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disabled tests will be re-enabled in another PR I guess

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants