[PM-14800] Credential Exchange implementation preparation (#1198)

This commit is contained in:
Federico Maccaroni 2024-12-12 09:52:12 -03:00 committed by GitHub
parent 970f698e0d
commit 58260a9b95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 836 additions and 69 deletions

View File

@ -1,3 +1,4 @@
import AuthenticationServices
import BitwardenShared
import SwiftUI
import UIKit
@ -78,13 +79,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
_ scene: UIScene,
continue userActivity: NSUserActivity
) {
guard
let appProcessor,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL
else { return }
guard let appProcessor else {
return
}
appProcessor.handleAppLinks(incomingURL: incomingURL)
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL {
appProcessor.handleAppLinks(incomingURL: incomingURL)
}
#if compiler(>=6.0.3)
if #available(iOS 18.2, *),
userActivity.activityType == ASCredentialExchangeActivity {
guard let token = userActivity.userInfo?[ASCredentialImportToken] as? UUID else {
return
}
appProcessor.handleImportCredentials(credentialImportToken: token)
}
#endif
}
func scene(_ scene: UIScene, openURLContexts urlContexts: Set<UIOpenURLContext>) {

View File

@ -4,6 +4,8 @@
<dict>
<key>BitwardenAppIdentifier</key>
<string>$(BASE_BUNDLE_ID)</string>
<key>BitwardenAuthenticatorSharedAppGroup</key>
<string>${SHARED_APP_GROUP_IDENTIFIER}</string>
<key>BitwardenKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BASE_BUNDLE_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
@ -150,7 +152,5 @@
</array>
<key>XSAppIconAssets</key>
<string>Resources/Assets.xcassets/AppIcons.appiconset</string>
<key>BitwardenAuthenticatorSharedAppGroup</key>
<string>${SHARED_APP_GROUP_IDENTIFIER}</string>
</dict>
</plist>

View File

@ -6,18 +6,18 @@
<string>$(BASE_BUNDLE_ID)</string>
<key>BitwardenKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BASE_BUNDLE_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Bitwarden</string>
<key>CFBundleName</key>
<string>Bitwarden Autofill</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
@ -56,37 +56,24 @@
<string>el</string>
<string>th</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Bitwarden Autofill</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
<dict>
<key>arm64</key>
<true/>
</dict>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>ITSEncryptionExportComplianceCode</key>
<string>ecf076d3-4824-4d7b-b716-2a9a47d7d296</string>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your vault.</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.authentication-services-credential-provider-ui</string>
<key>NSExtensionAttributes</key>
<dict>
<key>ASCredentialProviderExtensionShowsConfigurationUI</key>
<true/>
<key>ASCredentialProviderExtensionCapabilities</key>
<dict>
<key>ProvidesOneTimeCodes</key>
@ -98,7 +85,20 @@
<key>ShowsConfigurationUI</key>
<true/>
</dict>
<key>ASCredentialProviderExtensionShowsConfigurationUI</key>
<true/>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.authentication-services-credential-provider-ui</string>
</dict>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your vault.</string>
<key>UIRequiredDeviceCapabilities</key>
<dict>
<key>arm64</key>
<true/>
</dict>
</dict>
</plist>

View File

@ -1056,7 +1056,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
/// `isLocked` returns the lock state of an active user.
func test_isLocked_noHistory() async throws {
let account: Account = .fixture()
let account: BitwardenShared.Account = .fixture()
stateService.activeAccount = account
vaultTimeoutService.isClientLocked[account.profile.userId] = true
let isLocked = try await subject.isLocked()

View File

@ -4,6 +4,12 @@ import Foundation
/// An enum to represent a feature flag sent by the server
enum FeatureFlag: String, CaseIterable, Codable {
/// Flag to enable/disable Credential Exchange export flow.
case cxpExportMobile = "cxp-export-mobile"
/// Flag to enable/disable Credential Exchange import flow.
case cxpImportMobile = "cxp-import-mobile"
/// Flag to enable/disable email verification during registration
/// This flag introduces a new flow for account creation
case emailVerification = "email-verification"
@ -89,7 +95,9 @@ enum FeatureFlag: String, CaseIterable, Codable {
.testLocalInitialIntFlag,
.testLocalInitialStringFlag:
false
case .emailVerification,
case .cxpExportMobile,
.cxpImportMobile,
.emailVerification,
.enableAuthenticatorSync,
.refactorSsoDetailsEndpoint,
.sshKeyVaultItem,

View File

@ -14,6 +14,8 @@ final class FeatureFlagTests: BitwardenTestCase {
/// `getter:isRemotelyConfigured` returns the correct value for each flag.
func test_isRemotelyConfigured() {
XCTAssertTrue(FeatureFlag.cxpExportMobile.isRemotelyConfigured)
XCTAssertTrue(FeatureFlag.cxpImportMobile.isRemotelyConfigured)
XCTAssertTrue(FeatureFlag.emailVerification.isRemotelyConfigured)
XCTAssertTrue(FeatureFlag.enableAuthenticatorSync.isRemotelyConfigured)
XCTAssertTrue(FeatureFlag.refactorSsoDetailsEndpoint.isRemotelyConfigured)

View File

@ -58,19 +58,7 @@ extension JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom { keys in
let key = keys.last!.stringValue
let camelCaseKey: String
if key.contains("_") {
// Handle snake_case.
camelCaseKey = key.lowercased()
.split(separator: "_")
.enumerated()
.map { $0.offset > 0 ? $0.element.capitalized : $0.element.lowercased() }
.joined()
} else {
// Handle PascalCase or camelCase.
camelCaseKey = key.prefix(1).lowercased() + key.dropFirst()
}
return AnyKey(stringValue: camelCaseKey)
return AnyKey(stringValue: keyToCamelCase(key: key))
}
decoder.dateDecodingStrategy = defaultDecoder.dateDecodingStrategy
return decoder
@ -83,4 +71,47 @@ extension JSONDecoder {
decoder.dateDecodingStrategy = defaultDecoder.dateDecodingStrategy
return decoder
}()
/// A `JSONDecoder` instance that handles decoding JSON from CXP format to Apple's expected format.
static let cxpDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom { keys in
let key = keys.last!.stringValue
let camelCaseKey = keyToCamelCase(key: key)
return AnyKey(stringValue: customTransformCodingKeyForCXP(key: camelCaseKey))
}
decoder.dateDecodingStrategy = .secondsSince1970
return decoder
}()
// MARK: Static Functions
/// Transforms a snake_case, PascalCase or camelCase key into camelCase.
static func keyToCamelCase(key: String) -> String {
if key.contains("_") {
// Handle snake_case.
return key.lowercased()
.split(separator: "_")
.enumerated()
.map { $0.offset > 0 ? $0.element.capitalized : $0.element.lowercased() }
.joined()
}
// Handle PascalCase or camelCase.
return key.prefix(1).lowercased() + key.dropFirst()
}
// MARK: Private Static Functions
/// Transforms the keys from CXP format handled by the Bitwarden SDK into the keys that Apple expects.
private static func customTransformCodingKeyForCXP(key: String) -> String {
return switch key {
case "credentialId":
"credentialID"
case "rpId":
"rpID"
default:
key
}
}
}

View File

@ -6,15 +6,8 @@ struct APITestData {
let data: Data
static func loadFromBundle(resource: String, extension: String) -> APITestData {
let bundle = Bundle(for: BitwardenTestCase.self)
guard let url = bundle.url(forResource: resource, withExtension: `extension`) else {
fatalError("Unable to locate file \(resource).\(`extension`) in the bundle.")
}
do {
return try APITestData(data: Data(contentsOf: url))
} catch {
fatalError("Unable to load data from \(resource).\(`extension`) in the bundle. Error: \(error)")
}
let data = TestDataHelpers.loadFromBundle(resource: resource, extension: `extension`)
return APITestData(data: data)
}
static func loadFromJsonBundle(resource: String) -> APITestData {

View File

@ -0,0 +1,26 @@
import Foundation
/// A type that wraps fixture data for use in mocking responses during tests.
///
enum TestDataHelpers {
static func loadFromBundle(resource: String, extension: String) -> Data {
let bundle = Bundle(for: BitwardenTestCase.self)
guard let url = bundle.url(forResource: resource, withExtension: `extension`) else {
fatalError("Unable to locate file \(resource).\(`extension`) in the bundle.")
}
do {
return try Data(contentsOf: url)
} catch {
fatalError("Unable to load data from \(resource).\(`extension`) in the bundle. Error: \(error)")
}
}
static func loadFromJsonBundle(resource: String) -> Data {
loadFromBundle(resource: resource, extension: "json")
}
static func loadUTFStringFromJsonBundle(resource: String) -> String? {
let data = loadFromJsonBundle(resource: resource)
return String(data: data, encoding: .utf8)
}
}

View File

@ -28,7 +28,7 @@ protocol ClientService {
/// - Parameter userId: The user ID mapped to the client instance.
/// - Returns: A `ClientExportersProtocol` for vault export data tasks.
///
func exporters(for userId: String?) async throws -> ClientExportersProtocol
func exporters(for userId: String?) async throws -> ClientExportersServiceTemp
/// Returns a `ClientGeneratorsProtocol` for generator data tasks.
///
@ -88,7 +88,7 @@ extension ClientService {
/// Returns a `ClientExportersProtocol` for vault export data tasks.
///
func exporters() async throws -> ClientExportersProtocol {
func exporters() async throws -> ClientExportersServiceTemp {
try await exporters(for: nil)
}
@ -199,7 +199,7 @@ actor DefaultClientService: ClientService {
try await client(for: userId).crypto()
}
func exporters(for userId: String?) async throws -> ClientExportersProtocol {
func exporters(for userId: String?) async throws -> ClientExportersServiceTemp {
try await client(for: userId).exporters()
}
@ -360,7 +360,7 @@ protocol BitwardenSdkClient {
func crypto() -> ClientCryptoProtocol
/// Returns exporters.
func exporters() -> ClientExportersProtocol
func exporters() -> ClientExportersServiceTemp
/// Returns generator operations.
func generators() -> ClientGeneratorsProtocol
@ -386,7 +386,7 @@ extension Client: BitwardenSdkClient {
crypto() as ClientCrypto
}
func exporters() -> ClientExportersProtocol {
func exporters() -> ClientExportersServiceTemp {
exporters() as ClientExporters
}

View File

@ -37,7 +37,7 @@ class MockClient: BitwardenSdkClient {
""
}
func exporters() -> any ClientExportersProtocol {
func exporters() -> any ClientExportersServiceTemp {
clientExporters
}

View File

@ -44,7 +44,7 @@ class MockClientService: ClientService {
mockCrypto
}
func exporters(for userId: String?) -> ClientExportersProtocol {
func exporters(for userId: String?) -> ClientExportersServiceTemp {
mockExporters
}

View File

@ -0,0 +1,20 @@
import BitwardenSdk
/// API model for a request of a folder with id.
///
struct FolderWithIdRequestModel: Codable, Equatable {
// MARK: Properties
/// A identifier for the folder.
let id: String?
/// The name of the folder.
let name: String?
/// Inits a `FolderWithIdRequestModel` from a `Folder`
/// - Parameter folder: Folder from which initialize this request model.
init(folder: Folder) {
id = folder.id
name = folder.name
}
}

View File

@ -0,0 +1,32 @@
import Foundation
import Networking
// MARK: - ImportCiphersRequestModel
/// API request model for importing ciphers.
///
struct ImportCiphersRequestModel: JSONRequestBody {
// MARK: Properties
/// The cipher request models to import.
var ciphers: [CipherRequestModel]
/// The folders request models to import.
var folders: [FolderWithIdRequestModel]
/// The cipher<->folder relationships map. The key is the cipher index and the value is the folder index
/// in their respective arrays.
var folderRelationships: [FolderRelationship]
}
/// The cipher<->folder relationships map. The key is the cipher index and the value is the folder index
/// in their respective arrays.
struct FolderRelationship: Codable {
/// The key of the relationship which refers to the cipher index
/// of the `ciphers` array in `ImportCiphersRequestModel`.
let key: Int
/// The value of the relationship which refers to the folder index
/// of the `folders` array in `ImportCiphersRequestModel`.
let value: Int
}

View File

@ -0,0 +1,37 @@
import BitwardenSdk
import Networking
// MARK: - ImportCiphersAPIService
/// A protocol for an API service used to make import ciphers requests.
///
protocol ImportCiphersAPIService {
/// Performs an API request to import ciphers in the vault.
/// - Parameters:
/// - ciphers: The ciphers to import.
/// - folders: The folders to import.
/// - folderRelationships: The cipher<->folder relationships map. The key is the cipher index
/// and the value is the folder index in their respective arrays.
func importCiphers(
ciphers: [Cipher],
folders: [Folder],
folderRelationships: [(key: Int, value: Int)]
) async throws -> EmptyResponse
}
extension APIService: ImportCiphersAPIService {
func importCiphers(
ciphers: [Cipher],
folders: [Folder],
folderRelationships: [(key: Int, value: Int)]
) async throws -> EmptyResponse {
try await apiService
.send(
ImportCiphersRequest(
ciphers: ciphers,
folders: folders,
folderRelationships: folderRelationships
)
)
}
}

View File

@ -0,0 +1,52 @@
import XCTest
@testable import BitwardenShared
// MARK: - ImportCiphersAPIServiceTests
class ImportCiphersAPIServiceTests: BitwardenTestCase {
// MARK: Properties
var client: MockHTTPClient!
var subject: APIService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
client = MockHTTPClient()
subject = APIService(client: client)
}
override func tearDown() {
super.tearDown()
client = nil
subject = nil
}
// MARK: Tests
/// `importCiphers(ciphers:folders:folderRelationships:)` performs the import ciphers request.
func test_importCiphers() async throws {
client.results = [
.httpSuccess(testData: .emptyResponse),
]
_ = try await subject.importCiphers(ciphers: [.fixture()], folders: [], folderRelationships: [])
XCTAssertEqual(client.requests.count, 1)
XCTAssertNotNil(client.requests[0].body)
XCTAssertEqual(client.requests[0].method, .post)
XCTAssertEqual(client.requests[0].url.absoluteString, "https://example.com/api/ciphers/import")
}
/// `importCiphers(ciphers:folders:folderRelationships:)` performs the import ciphers request.
func test_importCiphers_throws() async throws {
client.results = [
.httpFailure(BitwardenTestError.example),
]
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.importCiphers(ciphers: [.fixture()], folders: [], folderRelationships: [])
}
}
}

View File

@ -0,0 +1,48 @@
import BitwardenSdk
import Networking
/// A request model for importing ciphers.
///
struct ImportCiphersRequest: Request {
typealias Response = EmptyResponse
// MARK: Properties
/// The body of the request.
var body: ImportCiphersRequestModel? {
requestModel
}
/// The HTTP method for this request.
let method = HTTPMethod.post
/// The URL path for this request.
let path = "/ciphers/import"
/// The request details to include in the body of the request.
let requestModel: ImportCiphersRequestModel
// MARK: Initialization
/// Initialize a `ImportCiphersRequest` for ciphers, folders and its relattionship.
/// - Parameters:
/// - ciphers: Ciphers to import.
/// - folders: Folders to import.
/// - folderRelationships: The cipher<->folder relationships map. The key is the cipher index
/// and the value is the folder index in their respective arrays.
init(
ciphers: [Cipher],
folders: [Folder] = [],
folderRelationships: [(key: Int, value: Int)] = []
) throws {
guard !ciphers.isEmpty else {
throw BitwardenError.dataError("There are no ciphers to import.")
}
requestModel = ImportCiphersRequestModel(
ciphers: ciphers.map { CipherRequestModel(cipher: $0) },
folders: folders.map { FolderWithIdRequestModel(folder: $0) },
folderRelationships: folderRelationships.map { FolderRelationship(key: $0.key, value: $0.value) }
)
}
}

View File

@ -0,0 +1,41 @@
import XCTest
@testable import BitwardenShared
// MARK: - ImportCiphersRequestTests
class ImportCiphersRequestTests: BitwardenTestCase {
// MARK: Tests
/// `init(ciphers:folders:folderRelationships:)` initializes the request successfully.
func test_init() throws {
let subject = try ImportCiphersRequest(
ciphers: [.fixture(name: "cipherTest")],
folders: [.fixture(name: "folderTest")],
folderRelationships: [(1, 1)]
)
XCTAssertEqual(subject.body?.ciphers[0].name, "cipherTest")
XCTAssertEqual(subject.body?.folders[0].name, "folderTest")
XCTAssertEqual(subject.body?.folderRelationships[0].key, 1)
XCTAssertEqual(subject.body?.folderRelationships[0].value, 1)
}
/// `init(ciphers:folders:folderRelationships:)` initializes the request successfully.
func test_init_throws() throws {
XCTAssertThrowsError(_ = try ImportCiphersRequest(
ciphers: []
))
}
/// `path` returns the correct path.
func test_path() throws {
let subject = try ImportCiphersRequest(ciphers: [.fixture()])
XCTAssertEqual(subject.path, "/ciphers/import")
}
/// `method` is `.put`.
func test_method() throws {
let subject = try ImportCiphersRequest(ciphers: [.fixture()])
XCTAssertEqual(subject.method, .post)
}
}

View File

@ -0,0 +1,39 @@
// swiftlint:disable:this file_name
import BitwardenSdk
/// Temporary protocol of `ClientExportersProtocol` until the SDK PR gets merged and is available for CI
/// https://github.com/bitwarden/sdk-internal/pull/32
protocol ClientExportersServiceTemp: AnyObject {
/// Exports ciphers with an account in Credential Exchange flow.
func exportCxf(account: BitwardenSdkAccount, ciphers: [BitwardenSdk.Cipher]) throws -> String
/// Exports organization vault in a given format.
func exportOrganizationVault(collections: [Collection], ciphers: [Cipher], format: ExportFormat) throws -> String
/// Exports vault with a given format.
func exportVault(folders: [Folder], ciphers: [Cipher], format: ExportFormat) throws -> String
/// Imports ciphers in Credential Exchange flow.
func importCxf(payload: String) throws -> [BitwardenSdk.Cipher]
}
/// Mocking the responses of the export CXP flow until the SDK PR gets merged.
extension ClientExporters: ClientExportersServiceTemp {
func exportCxf(account: BitwardenSdkAccount, ciphers: [BitwardenSdk.Cipher]) throws -> String {
"""
{"items":[{"modifiedAt":1732226366,"creationAt":1732226366,"title":"GitHub","credentials":[{"urls":["github.com"],"password":{"id":"RTEzRDEwQjctRTdCQy00QTI3LTgwNDAtRjgxMzNBOTMxMjhC","fieldType":"concealed-string","value":"adsfasf"},"type":"basic-auth","username":{"fieldType":"string","id":"NTlBMUFBNUYtODE5My00QUIzLThGRjYtOEFCRUQ5MUQxNUZG","value":"TestCXP1"}}],"id":"MjZDQzQwQTQtQUZDQS00NEIzLUEwNjAtMUMyNUUzNTc1RTZB","type":"login"},{"type":"login","modifiedAt":1732226380,"id":"NEMzOTY4MTItRTMxMi00NUExLUE4NDYtRUFENEZDMTkyMDJC","creationAt":1732226380,"title":"Google","credentials":[{"urls":["google.com"],"type":"basic-auth","username":{"id":"MTdCOUI5NTUtM0FGOC00RDYzLUEwN0UtQjJFMjk1MTM1NDlC","fieldType":"string","value":"TestCXPGoogle"},"password":{"fieldType":"concealed-string","value":"1o23j1po3ij1o","id":"QTU2NDVDMTktMTgzQy00OEJELUI4NTMtNzg4NjYzRDk2NzI1"}}]}],"id":"RDQxRjU3QTYtM0NFNi00MTI5LUI0MkUtNUZBOUY0NkU3QTFD","collections":[],"email":"","userName":""}
""" // swiftlint:disable:previous line_length
}
func importCxf(payload: String) throws -> [BitwardenSdk.Cipher] {
[.fixture(), .fixture()]
}
}
/// A temporary SDK Account to be used when exporting CXP.
public struct BitwardenSdkAccount {
let id: String
let email: String
let name: String?
}

View File

@ -1,3 +1,4 @@
import AuthenticationServices
import BitwardenSdk
import Foundation
@ -42,6 +43,13 @@ protocol ExportVaultService: AnyObject {
///
func exportVaultFileContents(format: ExportFileType) async throws -> String
#if compiler(>=6.0.3)
/// Exports the vault creating the `ASImportableAccount` to be used in Credential Exchange Protocol.
/// - Returns: An `ASImportableAccount`
@available(iOS 18.2, *)
func exportVaultForCXP() async throws -> ASImportableAccount
#endif
/// Generates a file name for the export file based on the current date, time, and specified extension.
/// - Parameters:
/// - prefix: An optional prefix to include in the file name. Defaults to nil.
@ -188,6 +196,25 @@ class DefultExportVaultService: ExportVaultService {
)
}
#if compiler(>=6.0.3)
@available(iOS 18.2, *)
func exportVaultForCXP() async throws -> ASImportableAccount {
let ciphers = try await cipherService.fetchAllCiphers()
.filter { $0.deletedDate == nil }
let account = try await stateService.getAccount(userId: nil)
let sdkAccount = BitwardenSdkAccount(
id: account.profile.userId,
email: account.profile.email,
name: account.profile.name
)
let serializedCXF = try await clientService.exporters().exportCxf(account: sdkAccount, ciphers: ciphers)
return try JSONDecoder.cxpDecoder.decode(ASImportableAccount.self, from: Data(serializedCXF.utf8))
}
#endif
func generateExportFileName(
prefix: String?,
extension fileExtension: String

View File

@ -1,11 +1,12 @@
import BitwardenSdk
import InlineSnapshotTesting
import XCTest
@testable import BitwardenShared
// MARK: - ExportVaultServiceTests
final class ExportVaultServiceTests: BitwardenTestCase {
final class ExportVaultServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
let cardCipher = Cipher(
@ -192,6 +193,139 @@ final class ExportVaultServiceTests: BitwardenTestCase {
// MARK: Tests
#if compiler(>=6.0.3)
/// `exportVaultForCXP()` CXP exporting the vault succeeds.
func test_exportVaultForCXP_success() async throws {
guard #available(iOS 18.2, *) else {
throw XCTSkip("CXP Export is only available on iOS 18.2")
}
stateService.activeAccount =
.fixture(
profile: .fixture(
email: "example@example.com",
name: "Test",
userDecryptionOptions: nil,
userId: "1"
)
)
let ciphers: [Cipher] = [.fixture(), .fixture(deletedDate: .now)]
cipherService.fetchAllCiphersResult = .success(ciphers)
let exportedResult = try XCTUnwrap(CXFFixtures.twoBasicAuthCiphers)
clientService.mockExporters.exportCxfResult = .success(exportedResult)
let result = try await subject.exportVaultForCXP()
assertInlineSnapshot(of: result.dump(), as: .lines) {
"""
Email:
UserName:
--- Items ---
Title: GitHub
Type: login
Creation: 2024-11-21 21:59:26 +0000
Modified: 2024-11-21 21:59:26 +0000
--- Credentials ---
Username.FieldType: string
Username.Value: TestCXP1
Password.FieldType: concealedString
Password.Value: adsfasf
--- Urls ---
github.com
Title: Google
Type: login
Creation: 2024-11-21 21:59:40 +0000
Modified: 2024-11-21 21:59:40 +0000
--- Credentials ---
Username.FieldType: string
Username.Value: TestCXPGoogle
Password.FieldType: concealedString
Password.Value: 1o23j1po3ij1o
--- Urls ---
google.com
"""
}
}
/// `exportVaultForCXP()` CXP exporting throws when fetching ciphers.
func test_exportVaultForCXP_throwsFetchingCiphers() async throws {
guard #available(iOS 18.2, *) else {
throw XCTSkip("CXP Export is only available on iOS 18.2")
}
cipherService.fetchAllCiphersResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.exportVaultForCXP()
}
}
/// `exportVaultForCXP()` CXP exporting throws when getting account.
func test_exportVaultForCXP_throwsGettingAccount() async throws {
guard #available(iOS 18.2, *) else {
throw XCTSkip("CXP Export is only available on iOS 18.2")
}
stateService.activeAccount = nil
let ciphers: [Cipher] = [.fixture(), .fixture(deletedDate: .now)]
cipherService.fetchAllCiphersResult = .success(ciphers)
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.exportVaultForCXP()
}
}
/// `exportVaultForCXP()` CXP exporting throws when exporting.
func test_exportVaultForCXP_throwsExporting() async throws {
guard #available(iOS 18.2, *) else {
throw XCTSkip("CXP Export is only available on iOS 18.2")
}
stateService.activeAccount =
.fixture(
profile: .fixture(
email: "example@example.com",
name: "Test",
userDecryptionOptions: nil,
userId: "1"
)
)
let ciphers: [Cipher] = [.fixture(), .fixture(deletedDate: .now)]
cipherService.fetchAllCiphersResult = .success(ciphers)
clientService.mockExporters.exportCxfResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.exportVaultForCXP()
}
}
/// `exportVaultForCXP()` CXP exporting throws when decoding exported data.
func test_exportVaultForCXP_throwsWhenDecoding() async throws {
guard #available(iOS 18.2, *) else {
throw XCTSkip("CXP Export is only available on iOS 18.2")
}
stateService.activeAccount =
.fixture(
profile: .fixture(
email: "example@example.com",
name: "Test",
userDecryptionOptions: nil,
userId: "1"
)
)
let ciphers: [Cipher] = [.fixture(), .fixture(deletedDate: .now)]
cipherService.fetchAllCiphersResult = .success(ciphers)
clientService.mockExporters.exportCxfResult = .success("{somethingthatcantbedecoded}")
do {
_ = try await subject.exportVaultForCXP()
} catch DecodingError.dataCorrupted {
XCTAssert(true)
} catch {
XCTFail("Export vault threw unexpected error: \(error)")
}
}
#endif
/// Test the exporter receives the correct content for CSV export type.
///
func test_fileContent_csv() async throws {
@ -297,4 +431,4 @@ final class ExportVaultServiceTests: BitwardenTestCase {
let name = subject.generateExportFileName(extension: fileType.fileExtension)
XCTAssertEqual(name, expectedName)
}
}
} // swiftlint:disable:this file_length

View File

@ -0,0 +1,7 @@
// swiftlint:disable:this file_name
/// Fixtures for Credential Exchange flows.
enum CXFFixtures {
/// Fixture to be used on export flow with two basic-auth ciphers.
static let twoBasicAuthCiphers = TestDataHelpers.loadUTFStringFromJsonBundle(resource: "cxfTwoBasicAuthCiphers")
}

View File

@ -0,0 +1,58 @@
{
"items": [
{
"modifiedAt": 1732226366,
"creationAt": 1732226366,
"title": "GitHub",
"credentials": [
{
"urls": [
"github.com"
],
"password": {
"id": "RTEzRDEwQjctRTdCQy00QTI3LTgwNDAtRjgxMzNBOTMxMjhC",
"fieldType": "concealed-string",
"value": "adsfasf"
},
"type": "basic-auth",
"username": {
"fieldType": "string",
"id": "NTlBMUFBNUYtODE5My00QUIzLThGRjYtOEFCRUQ5MUQxNUZG",
"value": "TestCXP1"
}
}
],
"id": "MjZDQzQwQTQtQUZDQS00NEIzLUEwNjAtMUMyNUUzNTc1RTZB",
"type": "login"
},
{
"type": "login",
"modifiedAt": 1732226380,
"id": "NEMzOTY4MTItRTMxMi00NUExLUE4NDYtRUFENEZDMTkyMDJC",
"creationAt": 1732226380,
"title": "Google",
"credentials": [
{
"urls": [
"google.com"
],
"type": "basic-auth",
"username": {
"id": "MTdCOUI5NTUtM0FGOC00RDYzLUEwN0UtQjJFMjk1MTM1NDlC",
"fieldType": "string",
"value": "TestCXPGoogle"
},
"password": {
"fieldType": "concealed-string",
"value": "1o23j1po3ij1o",
"id": "QTU2NDVDMTktMTgzQy00OEJELUI4NTMtNzg4NjYzRDk2NzI1"
}
}
]
}
],
"id": "RDQxRjU3QTYtM0NFNi00MTI5LUI0MkUtNUZBOUY0NkU3QTFD",
"collections": [],
"email": "",
"userName": ""
}

View File

@ -0,0 +1,102 @@
#if compiler(>=6.0.3)
import AuthenticationServices
@available(iOS 18.2, *)
extension ASImportableAccount {
/// Dumps the content of the `ASImportableAccount` into lines which can be used with
/// inline snapshot assertion.
func dump() -> String { // swiftlint:disable:this cyclomatic_complexity function_body_length
var dumpResult = ""
dumpResult.append("Email: \(email)\n")
dumpResult.append("UserName: \(userName)\n")
dumpResult.append("--- Items ---\n")
let itemsResult = items.reduce(into: "") { result, item in
result.appendWithIndentation("Title: \(item.title)\n")
result.appendWithIndentation("Type: \(item.type)\n")
result.appendWithIndentation("Creation: \(item.created)\n")
result.appendWithIndentation("Modified: \(item.lastModified)\n")
result.appendWithIndentation("--- Credentials ---\n")
let credentialsResult = item.credentials.reduce(into: "") { credResult, credential in
switch credential {
case let .basicAuthentication(basicAuthentication):
if let username = basicAuthentication.username {
credResult.appendWithIndentation("Username.FieldType: \(username.fieldType)\n", level: 2)
credResult.appendWithIndentation("Username.Value: \(username.value)\n", level: 2)
}
if let password = basicAuthentication.password {
credResult.appendWithIndentation("Password.FieldType: \(password.fieldType)\n", level: 2)
credResult.appendWithIndentation("Password.Value: \(password.value)\n", level: 2)
}
if !basicAuthentication.urls.isEmpty {
credResult.appendWithIndentation("--- Urls ---\n", level: 2)
let urlsResult = basicAuthentication.urls.reduce(into: "") { urlResult, url in
urlResult.appendWithIndentation(url, level: 3)
if url != basicAuthentication.urls.last {
urlResult.appendWithIndentation("\n\n", level: 3)
}
}
credResult.appendWithIndentation(urlsResult, level: 2)
}
case let .passkey(passkey):
credResult.appendWithIndentation("CredentialID: \(passkey.credentialID)\n", level: 2)
credResult.appendWithIndentation("Key: \(passkey.key)\n", level: 2)
credResult.appendWithIndentation(
"RelyingPartyIdentifier: \(passkey.relyingPartyIdentifier)\n",
level: 2
)
credResult.appendWithIndentation("UserDisplayName: \(passkey.userDisplayName)\n", level: 2)
credResult.appendWithIndentation("Username: \(passkey.userName)\n", level: 2)
case let .totp(totp):
credResult.appendWithIndentation("Algorithm: \(totp.algorithm)\n", level: 2)
credResult.appendWithIndentation("Digits: \(totp.digits)\n", level: 2)
if let issuer = totp.issuer {
credResult.appendWithIndentation("Issuer: \(issuer)\n", level: 2)
}
credResult.appendWithIndentation("Period: \(totp.period)\n", level: 2)
credResult.appendWithIndentation("Secret: \(totp.secret)\n", level: 2)
credResult.appendWithIndentation("Username: \(totp.username)\n", level: 2)
case let .note(note):
credResult.appendWithIndentation("Note: \(note.content)\n", level: 2)
case let .creditCard(card):
credResult.appendWithIndentation("FullName: \(card.fullName)\n", level: 2)
credResult.appendWithIndentation("Number: \(card.number)\n", level: 2)
if let cardType = card.cardType {
credResult.appendWithIndentation("CardType: \(cardType)\n", level: 2)
}
if let expiryDate = card.expiryDate {
credResult.appendWithIndentation("ExpiryDate: \(expiryDate)\n", level: 2)
}
if let validFrom = card.validFrom {
credResult.appendWithIndentation("ValidFrom: \(validFrom)\n", level: 2)
}
if let verificationNumber = card.verificationNumber {
credResult.appendWithIndentation("VerificationNumber: \(verificationNumber)\n", level: 2)
}
@unknown default:
result.append("unknown default\n")
}
if credential != item.credentials.last {
credResult.appendWithIndentation("\n\n", level: 2)
}
}
result.append(credentialsResult)
if item != items.last {
result.append("\n\n")
}
}
dumpResult.append(itemsResult)
return dumpResult
}
}
private extension String {
mutating func appendWithIndentation(_ other: String, level: Int = 1) {
let indentation = String(repeating: " ", count: level * 2)
append("\(indentation)\(other)")
}
}
#endif

View File

@ -86,7 +86,12 @@ extension CipherListViewType {
case .identity:
self = .identity
case .login:
self = .login(hasFido2: !(cipher.login?.fido2Credentials?.isEmpty ?? true), totp: cipher.login?.totp)
self = .login(
hasFido2: !(
cipher.login?.fido2Credentials?.isEmpty ?? true
),
totp: cipher.login?.totp
)
case .secureNote:
self = .secureNote
case .sshKey:

View File

@ -1,5 +1,7 @@
import BitwardenSdk
@testable import BitwardenShared
// MARK: - MockClientExporters
/// A mocked `ClientExportersProtocol`.
@ -7,12 +9,19 @@ import BitwardenSdk
class MockClientExporters {
// MARK: Properties
/// The ciphers exported in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`.
/// The account used in `exportOrganizationVault(_:)`.
var account: BitwardenSdkAccount?
/// The ciphers exported in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`
/// or `exportOrganizationVault(_:)`.
var ciphers = [BitwardenSdk.Cipher]()
/// The collections exported in a call to `exportOrganizationVault(_:)`.
var collections = [BitwardenSdk.Collection]()
/// The result of a call to `exportCxf(account:ciphers:)`
var exportCxfResult: Result<String, Error> = .failure(BitwardenTestError.example)
/// The result of a call to `exportOrganizationVault(_:)`
var exportOrganizationVaultResult: Result<String, Error> = .failure(BitwardenTestError.example)
@ -24,11 +33,23 @@ class MockClientExporters {
/// The format of the export in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`.
var format: BitwardenSdk.ExportFormat?
/// The payload passed to `importCxf(payload:)`
var importCxfPayload: String?
/// The result of a call to `importCxf(payload:)`
var importCxfResult: Result<[BitwardenSdk.Cipher], Error> = .failure(BitwardenTestError.example)
}
// MARK: - ClientExportersProtocol
extension MockClientExporters: ClientExportersProtocol {
extension MockClientExporters: ClientExportersServiceTemp {
func exportCxf(account: BitwardenSdkAccount, ciphers: [BitwardenSdk.Cipher]) throws -> String {
self.account = account
self.ciphers = ciphers
return try exportCxfResult.get()
}
func exportOrganizationVault(
collections: [BitwardenSdk.Collection],
ciphers: [BitwardenSdk.Cipher],
@ -50,4 +71,9 @@ extension MockClientExporters: ClientExportersProtocol {
self.format = format
return try exportVaultResult.get()
}
func importCxf(payload: String) throws -> [BitwardenSdk.Cipher] {
importCxfPayload = payload
return try importCxfResult.get()
}
}

View File

@ -1,10 +1,13 @@
import AuthenticationServices
import Foundation
import XCTest
@testable import BitwardenShared
class MockExportVaultService: ExportVaultService {
var didClearFiles = false
var exportVaultForCXPResult: Result<ImportableAccountProxy, Error> = .failure(BitwardenTestError.example)
var exportVaultContentsFormat: ExportFileType?
var exportVaultContentResult: Result<String, Error> = .failure(BitwardenTestError.example)
@ -16,6 +19,16 @@ class MockExportVaultService: ExportVaultService {
didClearFiles = true
}
#if compiler(>=6.0.3)
@available(iOS 18.2, *)
func exportVaultForCXP() async throws -> ASImportableAccount {
guard let result = try exportVaultForCXPResult.get() as? ASImportableAccount else {
throw MockExportVaultServiceError.unableToCastToASImportableAccount
}
return result
}
#endif
func exportVaultFileContents(format: BitwardenShared.ExportFileType) async throws -> String {
exportVaultContentsFormat = format
return try exportVaultContentResult.get()
@ -32,3 +45,14 @@ class MockExportVaultService: ExportVaultService {
try writeToFileResult.get()
}
}
protocol ImportableAccountProxy {}
#if compiler(>=6.0.3)
@available(iOS 18.2, *)
extension ASImportableAccount: ImportableAccountProxy {}
#endif
enum MockExportVaultServiceError: Error {
case unableToCastToASImportableAccount
}

View File

@ -216,6 +216,15 @@ public class AppProcessor {
)))
}
/// Handles importing credentials using Credential Exchange Protocol.
/// - Parameter credentialImportToken: The credentials import token to user with the `ASCredentialImportManager`.
@available(iOSApplicationExtension 18.2, *)
public func handleImportCredentials(credentialImportToken: UUID) {
// TODO: PM-14800 Move this to a specific view to handle importing process
// and handle credential data.
// let credentialData = try await ASCredentialImportManager().importCredentials(token: credentialImportToken)
}
// MARK: Autofill Methods
/// Returns a `ASPasswordCredential` that matches the user-requested credential which can be

View File

@ -1059,3 +1059,5 @@
"CopyPrivateKey" = "Copy private key";
"CopyFingerprint" = "Copy fingerprint";
"SSHKeys" = "SSH keys";
"ExportingFailed" = "Exporting failed";
"YouMayNeedToEnableDevicePasscodeOrBiometrics" = "You may need to enable device passcode or biometrics.";

View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
#
# Updates the Info.plist from Bitwarden Autofill extension to support Credential Exchange.
#
# :warning: Note: This script should be removed when we're ready to relase CXP to production.
#
# Usage:
#
# $ ./alpha_update_cxp_infoplist.sh
set -euo pipefail
autofill_info_plist_path="BitwardenAutoFillExtension/Application/Support/Info.plist"
app_info_plist_path="Bitwarden/Application/Support/Info.plist"
if ! grep -q "SupportsCredentialExchange" $autofill_info_plist_path; then
plutil -insert NSExtension.NSExtensionAttributes.ASCredentialProviderExtensionCapabilities.SupportsCredentialExchange -bool YES $autofill_info_plist_path
fi
if ! grep -q "ASCredentialExchangeActivityType" $app_info_plist_path; then
if ! grep -q "NSUserActivityTypes" $app_info_plist_path; then
plutil -insert NSUserActivityTypes -array $app_info_plist_path
fi
plutil -insert NSUserActivityTypes -string ASCredentialExchangeActivityType -append $app_info_plist_path
fi

View File

@ -88,3 +88,7 @@ cat << EOF > ${export_options_file}
</dict>
</plist>
EOF
if [[ $compiler_flags == *"SUPPORTS_CXP"* ]]; then
./alpha_update_cxp_infoplist.sh
fi