Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-14800] Implement Credential Exchange protocol #1138

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
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
27 changes: 21 additions & 6 deletions Bitwarden/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AuthenticationServices
import BitwardenShared
import SwiftUI
import UIKit
Expand Down Expand Up @@ -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
}

if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL {
appProcessor.handleAppLinks(incomingURL: incomingURL)
}

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>) {
Expand Down
4 changes: 4 additions & 0 deletions Bitwarden/Application/Support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,9 @@
<string>Resources/Assets.xcassets/AppIcons.appiconset</string>
<key>BitwardenAuthenticatorSharedAppGroup</key>
<string>${SHARED_APP_GROUP_IDENTIFIER}</string>
<key>NSUserActivityTypes</key>
<array>
<string>ASCredentialExchangeActivityType</string>
</array>
</dict>
</plist>
2 changes: 2 additions & 0 deletions BitwardenAutoFillExtension/Application/Support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
<true/>
<key>ASCredentialProviderExtensionCapabilities</key>
<dict>
<key>SupportsCredentialExchange</key>
<true/>
<key>ProvidesOneTimeCodes</key>
<true/>
<key>ProvidesPasskeys</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ enum ExportFormatType: Menuable {
/// A CSV file.
case csv

/// Using Credential Exchange Protocol.
case cxp

/// A JSON file.
case json

Expand All @@ -14,8 +17,13 @@ enum ExportFormatType: Menuable {

// MARK: Type Properties

#if compiler(>=6.0.3)
/// The ordered list of options to display in the menu.
static let allCases: [ExportFormatType] = [.json, .csv, .jsonEncrypted, .cxp]
#else
/// The ordered list of options to display in the menu.
static let allCases: [ExportFormatType] = [.json, .csv, .jsonEncrypted]
#endif

// MARK: Properties

Expand All @@ -24,6 +32,8 @@ enum ExportFormatType: Menuable {
switch self {
case .csv:
".csv"
case .cxp:
"Credential Exchange"
case .json:
".json"
case .jsonEncrypted:
Expand Down
10 changes: 9 additions & 1 deletion BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -89,7 +95,9 @@ enum FeatureFlag: String, CaseIterable, Codable {
.testLocalInitialIntFlag,
.testLocalInitialStringFlag:
false
case .emailVerification,
case .cxpExportMobile,
.cxpImportMobile,
.emailVerification,
.enableAuthenticatorSync,
.refactorSsoDetailsEndpoint,
.sshKeyVaultItem,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
// not both at the same time ๐Ÿ™ƒ. Since the API contains both formats with the more
// common containing fractional seconds, attempt to parse that first and fall back to
// parsing without fractional seconds.
if let date = dateFormatterWithFractionalSeconds.date(from: stringValue) {

Check warning on line 42 in BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoder+Bitwarden.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'dateFormatterWithFractionalSeconds' with non-sendable type 'ISO8601DateFormatter' in a `@Sendable` closure; this is an error in the Swift 6 language mode
return date
} else if let date = dateFormatter.date(from: stringValue) {

Check warning on line 44 in BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoder+Bitwarden.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'dateFormatter' with non-sendable type 'ISO8601DateFormatter' in a `@Sendable` closure; this is an error in the Swift 6 language mode
return date
} else {
throw DecodingError.dataCorruptedError(
Expand All @@ -58,19 +58,7 @@
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
Expand All @@ -83,4 +71,45 @@
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 the keys from CXP format handled by the Bitwarden SDK into the keys that Apple expects.
static func customTransformCodingKeyForCXP(key: String) -> String {
return switch key {
case "credentialId":
"credentialID"
case "rpId":
"rpID"
default:
key
}
}

/// 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()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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: [Int: Int]
}
Original file line number Diff line number Diff line change
@@ -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: [Int: Int]
) async throws -> EmptyResponse
}

extension APIService: ImportCiphersAPIService {
func importCiphers(
ciphers: [Cipher],
folders: [Folder],
folderRelationships: [Int: Int]
) async throws -> EmptyResponse {
try await apiService
.send(
ImportCiphersRequest(
ciphers: ciphers,
folders: folders,
folderRelationships: folderRelationships
)
)
}
}
Original file line number Diff line number Diff line change
@@ -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: [:])
}
}
}
Original file line number Diff line number Diff line change
@@ -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: [Int: 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
)
}
}
Loading
Loading