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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public extension Account {
try? Keychain.deleteItem(item)

try? RemoveCoreCryptoKeysUseCase().invoke(userID: userIdentifier)
try? RemoveEARKeysUseCase().invoke(accountID: userIdentifier)
}

}
57 changes: 57 additions & 0 deletions wire-ios-data-model/Source/UseCases/RemoveEARKeysUseCase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
import WireLogging

// sourcery: AutoMockable
public protocol RemoveEARKeysUseCaseProtocol {

/// Removes all encryption at rest keys from the keychain for the given account.
func invoke(accountID: UUID) throws

}

public struct RemoveEARKeysUseCase: RemoveEARKeysUseCaseProtocol {

private let keyRepository: EARKeyRepositoryInterface

public init() {
self.keyRepository = EARKeyRepository()
}

init(keyRepository: EARKeyRepositoryInterface) {
self.keyRepository = keyRepository
}

public func invoke(accountID: UUID) throws {
do {
try keyRepository.deletePublicKey(description: .primaryKeyDescription(accountID: accountID))
try keyRepository.deletePrivateKey(description: .primaryKeyDescription(accountID: accountID, context: nil))
try keyRepository.deletePublicKey(description: .secondaryKeyDescription(accountID: accountID))
try keyRepository.deletePrivateKey(description: .secondaryKeyDescription(accountID: accountID))
try keyRepository.deleteDatabaseKey(description: .keyDescription(accountID: accountID))
} catch {
WireLogger.ear.error(
"failed to remove EAR keys for accountID: \(accountID) - error: \(String(describing: error))"
)
throw error
}
}

}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
import Testing
@testable import WireDataModel

@Suite
struct EARKeyRepositoryTests {

let sut: EARKeyRepository
let accountID: UUID
let keyGenerator: EARKeyGenerator

let primaryPublicDesc: PublicEARKeyDescription
let secondaryPublicDesc: PublicEARKeyDescription
let primaryPrivateDesc: PrivateEARKeyDescription
let secondaryPrivateDesc: PrivateEARKeyDescription
let databaseKeyDesc: DatabaseEARKeyDescription

init() {
self.sut = EARKeyRepository()
self.accountID = UUID()
self.keyGenerator = EARKeyGenerator()

self.primaryPublicDesc = .primaryKeyDescription(accountID: accountID)
self.secondaryPublicDesc = .secondaryKeyDescription(accountID: accountID)
self.primaryPrivateDesc = .primaryKeyDescription(accountID: accountID, context: nil)
self.secondaryPrivateDesc = .secondaryKeyDescription(accountID: accountID)
self.databaseKeyDesc = .keyDescription(accountID: accountID)
}

// MARK: - Public Key

@Test("Stores and fetches a public key")
func storeAndFetchPublicKey() throws {
// Given
let (publicKey, _) = try keyGenerator.generatePrimaryPublicPrivateKeyPair(id: "test-primary-\(accountID)")
defer { try? sut.deletePublicKey(description: primaryPublicDesc) }

// When
try sut.storePublicKey(description: primaryPublicDesc, key: publicKey)
let fetched = try sut.fetchPublicKey(description: primaryPublicDesc)

// Then
#expect(SecKeyCopyExternalRepresentation(fetched, nil) == SecKeyCopyExternalRepresentation(publicKey, nil))
}

@Test("Returns cached public key without hitting keychain again")
func fetchPublicKey_returnsFromCache() throws {

Check warning on line 65 in wire-ios-data-model/Tests/Authentication/EAR/EARKeyRepositoryTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "fetchPublicKey_returnsFromCache" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltSmm9_Jq1qAEoT&open=AZ1OzltSmm9_Jq1qAEoT&pullRequest=4532
// Given
let (publicKey, _) = try keyGenerator.generatePrimaryPublicPrivateKeyPair(id: "test-cache-\(accountID)")
try sut.storePublicKey(description: primaryPublicDesc, key: publicKey)

// populate cache
_ = try sut.fetchPublicKey(description: primaryPublicDesc)
// remove from keychain, only cache remains
try KeychainManager.deleteItem(primaryPublicDesc)

// When
let fetched = try sut.fetchPublicKey(description: primaryPublicDesc)

// Then
#expect(SecKeyCopyExternalRepresentation(fetched, nil) == SecKeyCopyExternalRepresentation(publicKey, nil))
}

@Test("Throws keyNotFound when public key is absent")
func fetchPublicKey_throwsKeyNotFound_whenAbsent() {

Check warning on line 83 in wire-ios-data-model/Tests/Authentication/EAR/EARKeyRepositoryTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "fetchPublicKey_throwsKeyNotFound_whenAbsent" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltSmm9_Jq1qAEoU&open=AZ1OzltSmm9_Jq1qAEoU&pullRequest=4532
#expect(throws: EARKeyRepositoryFailure.keyNotFound) {
try sut.fetchPublicKey(description: primaryPublicDesc)
}
}

@Test("Deletes public key from keychain and cache")
func deletePublicKey_removesFromKeychainAndCache() throws {

Check warning on line 90 in wire-ios-data-model/Tests/Authentication/EAR/EARKeyRepositoryTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "deletePublicKey_removesFromKeychainAndCache" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltSmm9_Jq1qAEoV&open=AZ1OzltSmm9_Jq1qAEoV&pullRequest=4532
// Given
let (publicKey, _) = try keyGenerator.generatePrimaryPublicPrivateKeyPair(id: "test-delete-\(accountID)")
try sut.storePublicKey(description: primaryPublicDesc, key: publicKey)

// populate cache
_ = try sut.fetchPublicKey(description: primaryPublicDesc)

// When
try sut.deletePublicKey(description: primaryPublicDesc)

// Then — cache cleared, keychain cleared
#expect(throws: EARKeyRepositoryFailure.keyNotFound) {
try sut.fetchPublicKey(description: primaryPublicDesc)
}
}

// MARK: - Database Key

@Test("Stores and fetches a database key")
func storeAndFetchDatabaseKey() throws {
// Given
let databaseKey = Data.randomEncryptionKey()
defer { try? sut.deleteDatabaseKey(description: databaseKeyDesc) }

// When
try sut.storeDatabaseKey(description: databaseKeyDesc, key: databaseKey)
let fetched = try sut.fetchDatabaseKey(description: databaseKeyDesc)

// Then
#expect(fetched == databaseKey)
}

@Test("Throws keyNotFound when database key is absent")
func fetchDatabaseKey_throwsKeyNotFound_whenAbsent() {

Check warning on line 124 in wire-ios-data-model/Tests/Authentication/EAR/EARKeyRepositoryTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "fetchDatabaseKey_throwsKeyNotFound_whenAbsent" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltSmm9_Jq1qAEoW&open=AZ1OzltSmm9_Jq1qAEoW&pullRequest=4532
#expect(throws: EARKeyRepositoryFailure.keyNotFound) {
try sut.fetchDatabaseKey(description: databaseKeyDesc)
}
}

@Test("Deletes database key from keychain")
func deleteDatabaseKey_removesFromKeychain() throws {

Check warning on line 131 in wire-ios-data-model/Tests/Authentication/EAR/EARKeyRepositoryTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "deleteDatabaseKey_removesFromKeychain" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltSmm9_Jq1qAEoX&open=AZ1OzltSmm9_Jq1qAEoX&pullRequest=4532
// Given
try sut.storeDatabaseKey(description: databaseKeyDesc, key: .randomEncryptionKey())

// When
try sut.deleteDatabaseKey(description: databaseKeyDesc)

// Then
#expect(throws: EARKeyRepositoryFailure.keyNotFound) {
try sut.fetchDatabaseKey(description: databaseKeyDesc)
}
}

// MARK: - Cache

@Test("clearCache forces a keychain refetch on next access")
func clearCache_forcesRefetchFromKeychain() throws {

Check warning on line 147 in wire-ios-data-model/Tests/Authentication/EAR/EARKeyRepositoryTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "clearCache_forcesRefetchFromKeychain" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltSmm9_Jq1qAEoY&open=AZ1OzltSmm9_Jq1qAEoY&pullRequest=4532
// Given
let (publicKey, _) = try keyGenerator.generatePrimaryPublicPrivateKeyPair(id: "test-clear-\(accountID)")
try sut.storePublicKey(description: primaryPublicDesc, key: publicKey)

// populate cache
_ = try sut.fetchPublicKey(description: primaryPublicDesc)
// remove from keychain
try KeychainManager.deleteItem(primaryPublicDesc)

// When
sut.clearCache()

// Then — cache is gone, keychain miss propagates
#expect(throws: EARKeyRepositoryFailure.keyNotFound) {
try sut.fetchPublicKey(description: primaryPublicDesc)
}
}

}
33 changes: 33 additions & 0 deletions wire-ios-data-model/Tests/Source/Model/AccountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,37 @@
XCTAssertThrowsError(try Keychain.fetchItem(item))
}

func test_deleteKeychainItems_removesEARKeysFromKeychain() throws {

Check warning on line 66 in wire-ios-data-model/Tests/Source/Model/AccountTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "test_deleteKeychainItems_removesEARKeysFromKeychain" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1Ozllumm9_Jq1qAEoM&open=AZ1Ozllumm9_Jq1qAEoM&pullRequest=4532
// Given
let accountID = UUID()
let account = Account(userName: "", userIdentifier: accountID)
let keyGenerator = EARKeyGenerator()
let keyRepository = EARKeyRepository()

let primaryPublicDesc = PublicEARKeyDescription.primaryKeyDescription(accountID: accountID)
let secondaryPublicDesc = PublicEARKeyDescription.secondaryKeyDescription(accountID: accountID)
let databaseKeyDesc = DatabaseEARKeyDescription.keyDescription(accountID: accountID)

let (primaryPublicKey, _) = try keyGenerator.generatePrimaryPublicPrivateKeyPair(id: "test-account-primary")
let (secondaryPublicKey, _) = try keyGenerator
.generateSecondaryPublicPrivateKeyPair(id: "test-account-secondary")
try keyRepository.storePublicKey(description: primaryPublicDesc, key: primaryPublicKey)
try keyRepository.storePublicKey(description: secondaryPublicDesc, key: secondaryPublicKey)
try keyRepository.storeDatabaseKey(description: databaseKeyDesc, key: .randomEncryptionKey())

// When
account.deleteKeychainItems()

// Then — all EAR keys are gone
XCTAssertThrowsError(try keyRepository.fetchPublicKey(description: primaryPublicDesc)) { error in
XCTAssertEqual(error as? EARKeyRepositoryFailure, .keyNotFound)
}
XCTAssertThrowsError(try keyRepository.fetchPublicKey(description: secondaryPublicDesc)) { error in
XCTAssertEqual(error as? EARKeyRepositoryFailure, .keyNotFound)
}
XCTAssertThrowsError(try keyRepository.fetchDatabaseKey(description: databaseKeyDesc)) { error in
XCTAssertEqual(error as? EARKeyRepositoryFailure, .keyNotFound)
}
}

}
87 changes: 87 additions & 0 deletions wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
import Testing
@testable import WireDataModel
@testable import WireDataModelSupport

@Suite
struct RemoveEARKeysUseCaseTests {

let sut: RemoveEARKeysUseCase
let mockKeyRepository: MockEARKeyRepositoryInterface
let accountID: UUID

init() {
self.accountID = UUID()
self.mockKeyRepository = MockEARKeyRepositoryInterface()
mockKeyRepository.deletePublicKeyDescription_MockMethod = { _ in }

Check failure on line 34 in wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltKmm9_Jq1qAEoN&open=AZ1OzltKmm9_Jq1qAEoN&pullRequest=4532
mockKeyRepository.deletePrivateKeyDescription_MockMethod = { _ in }

Check failure on line 35 in wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltKmm9_Jq1qAEoO&open=AZ1OzltKmm9_Jq1qAEoO&pullRequest=4532
mockKeyRepository.deleteDatabaseKeyDescription_MockMethod = { _ in }

Check failure on line 36 in wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltKmm9_Jq1qAEoP&open=AZ1OzltKmm9_Jq1qAEoP&pullRequest=4532
self.sut = RemoveEARKeysUseCase(keyRepository: mockKeyRepository)
}

@Test("Deletes all five EAR keys with correct descriptions")
func invoke_deletesAllExpectedKeys() throws {

Check warning on line 41 in wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "invoke_deletesAllExpectedKeys" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltKmm9_Jq1qAEoQ&open=AZ1OzltKmm9_Jq1qAEoQ&pullRequest=4532
// When
try sut.invoke(accountID: accountID)

// Then
let deletedPublicIDs = mockKeyRepository.deletePublicKeyDescription_Invocations.map(\.id)
let deletedPrivateIDs = mockKeyRepository.deletePrivateKeyDescription_Invocations.map(\.id)
let deletedDatabaseIDs = mockKeyRepository.deleteDatabaseKeyDescription_Invocations.map(\.id)

#expect(deletedPublicIDs.contains(PublicEARKeyDescription.primaryKeyDescription(accountID: accountID).id))
#expect(deletedPublicIDs.contains(PublicEARKeyDescription.secondaryKeyDescription(accountID: accountID).id))
#expect(deletedPrivateIDs.contains(PrivateEARKeyDescription.primaryKeyDescription(
accountID: accountID,
context: nil
).id))
#expect(deletedPrivateIDs.contains(PrivateEARKeyDescription.secondaryKeyDescription(accountID: accountID).id))
#expect(deletedDatabaseIDs.contains(DatabaseEARKeyDescription.keyDescription(accountID: accountID).id))
}

@Test("Does not delete keys belonging to a different account")
func invoke_doesNotDeleteOtherAccountKeys() throws {

Check warning on line 61 in wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "invoke_doesNotDeleteOtherAccountKeys" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltKmm9_Jq1qAEoR&open=AZ1OzltKmm9_Jq1qAEoR&pullRequest=4532
// Given
let otherAccountID = UUID()

// When
try sut.invoke(accountID: accountID)

// Then
let deletedPublicIDs = mockKeyRepository.deletePublicKeyDescription_Invocations.map(\.id)
#expect(!deletedPublicIDs.contains(PublicEARKeyDescription.primaryKeyDescription(accountID: otherAccountID).id))
#expect(!deletedPublicIDs
.contains(PublicEARKeyDescription.secondaryKeyDescription(accountID: otherAccountID).id))
}

@Test("Propagates repository error")
func invoke_propagatesRepositoryError() {

Check warning on line 76 in wire-ios-data-model/Tests/UseCases/RemoveEARKeysUseCaseTests.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "invoke_propagatesRepositoryError" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-ios&issues=AZ1OzltKmm9_Jq1qAEoS&open=AZ1OzltKmm9_Jq1qAEoS&pullRequest=4532
// Given
struct DeletionError: Error {}
mockKeyRepository.deletePublicKeyDescription_MockError = DeletionError()

// When / Then
#expect(throws: DeletionError.self) {
try sut.invoke(accountID: accountID)
}
}

}
Loading
Loading