From 6af83fce35e30403e7b9c468661271724e709437 Mon Sep 17 00:00:00 2001
From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com>
Date: Thu, 12 Sep 2024 09:51:57 -0400
Subject: [PATCH] [BITAU-137] Create AuthenticatorSyncKit SDK (#913)
---
.../AuthenticatorKeychainService.swift | 38 ++++
AuthenticatorSyncKit/Info.plist | 18 ++
.../SharedKeychainRepository.swift | 155 ++++++++++++++
.../Tests/SharedKeychainRepositoryTests.swift | 126 +++++++++++
.../MockAuthenticatorKeychainService.swift | 40 ++++
.../MockSharedKeychainRepository.swift | 31 +++
.../AuthenticatorSyncKitTestCase.swift | 198 ++++++++++++++++++
.../Tests/TestHelpers/Support/Info.plist | 22 ++
.../Core/Auth/Services/KeychainService.swift | 5 +
Configs/AuthenticatorSyncKit.xcconfig | 4 +
Package.swift | 32 +++
project.yml | 41 ++++
12 files changed, 710 insertions(+)
create mode 100644 AuthenticatorSyncKit/AuthenticatorKeychainService.swift
create mode 100644 AuthenticatorSyncKit/Info.plist
create mode 100644 AuthenticatorSyncKit/SharedKeychainRepository.swift
create mode 100644 AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift
create mode 100644 AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift
create mode 100644 AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift
create mode 100644 AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift
create mode 100644 AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist
create mode 100644 Configs/AuthenticatorSyncKit.xcconfig
create mode 100644 Package.swift
diff --git a/AuthenticatorSyncKit/AuthenticatorKeychainService.swift b/AuthenticatorSyncKit/AuthenticatorKeychainService.swift
new file mode 100644
index 000000000..2a55a0129
--- /dev/null
+++ b/AuthenticatorSyncKit/AuthenticatorKeychainService.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+// MARK: - AuthenticatorKeychainService
+
+/// A Service to provide a wrapper around the device keychain shared via App Group between
+/// the Authenticator and the main Bitwarden app.
+///
+public protocol AuthenticatorKeychainService: AnyObject {
+ /// Adds a set of attributes.
+ ///
+ /// - Parameter attributes: Attributes to add.
+ ///
+ func add(attributes: CFDictionary) throws
+
+ /// Attempts a deletion based on a query.
+ ///
+ /// - Parameter query: Query for the delete.
+ ///
+ func delete(query: CFDictionary) throws
+
+ /// Searches for a query.
+ ///
+ /// - Parameter query: Query for the search.
+ /// - Returns: The search results.
+ ///
+ func search(query: CFDictionary) throws -> AnyObject?
+}
+
+// MARK: - AuthenticatorKeychainServiceError
+
+/// Enum with possible error cases that can be thrown from `AuthenticatorKeychainService`.
+public enum AuthenticatorKeychainServiceError: Error, Equatable {
+ /// When a `KeychainService` is unable to locate an auth key for a given storage key.
+ ///
+ /// - Parameter KeychainItem: The potential storage key for the auth key.
+ ///
+ case keyNotFound(SharedKeychainItem)
+}
diff --git a/AuthenticatorSyncKit/Info.plist b/AuthenticatorSyncKit/Info.plist
new file mode 100644
index 000000000..621b0bd75
--- /dev/null
+++ b/AuthenticatorSyncKit/Info.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleName
+ AuthenticatorSyncKit
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+
+
diff --git a/AuthenticatorSyncKit/SharedKeychainRepository.swift b/AuthenticatorSyncKit/SharedKeychainRepository.swift
new file mode 100644
index 000000000..73c96d6de
--- /dev/null
+++ b/AuthenticatorSyncKit/SharedKeychainRepository.swift
@@ -0,0 +1,155 @@
+import Foundation
+
+// MARK: - SharedKeychainItem
+
+/// Enumeration of support Keychain Items that can be placed in the `SharedKeychainRepository`
+///
+public enum SharedKeychainItem: Equatable {
+ /// The keychain item for the authenticator encryption key.
+ case authenticatorKey
+
+ /// The storage key for this keychain item.
+ ///
+ var unformattedKey: String {
+ switch self {
+ case .authenticatorKey:
+ "authenticatorKey"
+ }
+ }
+}
+
+// MARK: - SharedKeychainRepository
+
+/// A repository for managing keychain items to be shared between the main Bitwarden app and the Authenticator app.
+///
+public protocol SharedKeychainRepository: AnyObject {
+ /// Attempts to delete the authenticator key from the keychain.
+ ///
+ func deleteAuthenticatorKey() throws
+
+ /// Gets the authenticator key.
+ ///
+ /// - Returns: Data representing the authenticator key.
+ ///
+ func getAuthenticatorKey() async throws -> Data
+
+ /// Stores the access token for a user in the keychain.
+ ///
+ /// - Parameter value: The authenticator key to store.
+ ///
+ func setAuthenticatorKey(_ value: Data) async throws
+}
+
+// MARK: - DefaultKeychainRepository
+
+/// A concreate implementation of the `SharedKeychainRepository` protocol.
+///
+public class DefaultSharedKeychainRepository: SharedKeychainRepository {
+ // MARK: Properties
+
+ /// An identifier for the shared access group used by the application.
+ ///
+ /// Example: "group.com.8bit.bitwarden"
+ ///
+ private let sharedAppGroupIdentifier: String
+
+ /// The keychain service used by the repository
+ ///
+ private let keychainService: AuthenticatorKeychainService
+
+ // MARK: Initialization
+
+ /// Initialize a `DefaultSharedKeychainRepository`.
+ ///
+ /// - Parameters:
+ /// - sharedAppGroupIdentifier: An identifier for the shared access group used by the application.
+ /// - keychainService: The keychain service used by the repository
+ public init(
+ sharedAppGroupIdentifier: String,
+ keychainService: AuthenticatorKeychainService
+ ) {
+ self.sharedAppGroupIdentifier = sharedAppGroupIdentifier
+ self.keychainService = keychainService
+ }
+
+ // MARK: Methods
+
+ /// Retrieve the value for the specific item from the Keychain Service.
+ ///
+ /// - Parameter item: the keychain item for which to retrieve a value.
+ /// - Returns: The value (Data) stored in the keychain for the given item.
+ ///
+ private func getSharedValue(for item: SharedKeychainItem) async throws -> Data {
+ let foundItem = try keychainService.search(
+ query: [
+ kSecMatchLimit: kSecMatchLimitOne,
+ kSecReturnData: true,
+ kSecReturnAttributes: true,
+ kSecAttrAccessGroup: sharedAppGroupIdentifier,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecAttrAccount: item.unformattedKey,
+ kSecClass: kSecClassGenericPassword,
+ ] as CFDictionary
+ )
+
+ guard let resultDictionary = foundItem as? [String: Any],
+ let data = resultDictionary[kSecValueData as String] as? Data else {
+ throw AuthenticatorKeychainServiceError.keyNotFound(item)
+ }
+
+ return data
+ }
+
+ /// Store a given value into the keychain for the given item.
+ ///
+ /// - Parameters:
+ /// - value: The value (Data) to be stored into the keychain
+ /// - item: The item for which to store the value in the keychain.
+ ///
+ private func setSharedValue(_ value: Data, for item: SharedKeychainItem) async throws {
+ let query = [
+ kSecValueData: value,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecAttrAccessGroup: sharedAppGroupIdentifier,
+ kSecAttrAccount: item.unformattedKey,
+ kSecClass: kSecClassGenericPassword,
+ ] as CFDictionary
+
+ try? keychainService.delete(query: query)
+
+ try keychainService.add(
+ attributes: query
+ )
+ }
+}
+
+public extension DefaultSharedKeychainRepository {
+ /// Attempts to delete the authenticator key from the keychain.
+ ///
+ func deleteAuthenticatorKey() throws {
+ try keychainService.delete(
+ query: [
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecAttrAccessGroup: sharedAppGroupIdentifier,
+ kSecAttrAccount: SharedKeychainItem.authenticatorKey.unformattedKey,
+ kSecClass: kSecClassGenericPassword,
+ ] as CFDictionary
+ )
+ }
+
+ /// Gets the authenticator key.
+ ///
+ /// - Returns: Data representing the authenticator key.
+ ///
+ func getAuthenticatorKey() async throws -> Data {
+ try await getSharedValue(for: .authenticatorKey)
+ }
+
+ /// Stores the access token for a user in the keychain.
+ ///
+ /// - Parameter value: The authenticator key to store.
+ ///
+ func setAuthenticatorKey(_ value: Data) async throws {
+ try await setSharedValue(value, for: .authenticatorKey)
+ }
+}
diff --git a/AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift b/AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift
new file mode 100644
index 000000000..fcf497dbd
--- /dev/null
+++ b/AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift
@@ -0,0 +1,126 @@
+import CryptoKit
+import Foundation
+import XCTest
+
+@testable import AuthenticatorSyncKit
+
+final class SharedKeychainRepositoryTests: AuthenticatorSyncKitTestCase {
+ // MARK: Properties
+
+ let accessGroup = "group.com.example.bitwarden"
+ var keychainService: MockAuthenticatorKeychainService!
+ var subject: DefaultSharedKeychainRepository!
+
+ // MARK: Setup & Teardown
+
+ override func setUp() {
+ keychainService = MockAuthenticatorKeychainService()
+ subject = DefaultSharedKeychainRepository(
+ sharedAppGroupIdentifier: accessGroup,
+ keychainService: keychainService
+ )
+ }
+
+ override func tearDown() {
+ keychainService = nil
+ subject = nil
+ }
+
+ // MARK: Tests
+
+ /// Verify that `deleteAuthenticatorKey()` issues a delete with the correct search attributes specified.
+ ///
+ func test_deleteAuthenticatorKey_success() async throws {
+ try subject.deleteAuthenticatorKey()
+
+ let queries = try XCTUnwrap(keychainService.deleteQueries as? [[CFString: Any]])
+ XCTAssertEqual(queries.count, 1)
+
+ let query = try XCTUnwrap(queries.first)
+ try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessGroup] as? String), accessGroup)
+ try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessible] as? String),
+ String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
+ try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccount] as? String),
+ SharedKeychainItem.authenticatorKey.unformattedKey)
+ try XCTAssertEqual(XCTUnwrap(query[kSecClass] as? String),
+ String(kSecClassGenericPassword))
+ }
+
+ /// Verify that `getAuthenticatorKey()` returns a value successfully when one is set. Additionally, verify the
+ /// search attributes are specified correctly.
+ ///
+ func test_getAuthenticatorKey_success() async throws {
+ let key = SymmetricKey(size: .bits256)
+ let data = key.withUnsafeBytes { Data(Array($0)) }
+
+ keychainService.setSearchResultData(data)
+
+ let returnData = try await subject.getAuthenticatorKey()
+ XCTAssertEqual(returnData, data)
+
+ let query = try XCTUnwrap(keychainService.searchQuery as? [CFString: Any])
+ try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessGroup] as? String), accessGroup)
+ try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessible] as? String),
+ String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
+ try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccount] as? String),
+ SharedKeychainItem.authenticatorKey.unformattedKey)
+ try XCTAssertEqual(XCTUnwrap(query[kSecClass] as? String), String(kSecClassGenericPassword))
+ try XCTAssertEqual(XCTUnwrap(query[kSecMatchLimit] as? String), String(kSecMatchLimitOne))
+ try XCTAssertTrue(XCTUnwrap(query[kSecReturnAttributes] as? Bool))
+ try XCTAssertTrue(XCTUnwrap(query[kSecReturnData] as? Bool))
+ }
+
+ /// Verify that `getAuthenticatorKey()` fails with a `keyNotFound` error when an unexpected
+ /// result is returned instead of the key data from the keychain
+ ///
+ func test_getAuthenticatorKey_badResult() async throws {
+ let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)
+ keychainService.searchResult = .success([kSecValueData as String: NSObject()] as AnyObject)
+
+ await assertAsyncThrows(error: error) {
+ _ = try await subject.getAuthenticatorKey()
+ }
+ }
+
+ /// Verify that `getAuthenticatorKey()` fails with a `keyNotFound` error when a nil
+ /// result is returned instead of the key data from the keychain
+ ///
+ func test_getAuthenticatorKey_nilResult() async throws {
+ let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)
+ keychainService.searchResult = .success(nil)
+
+ await assertAsyncThrows(error: error) {
+ _ = try await subject.getAuthenticatorKey()
+ }
+ }
+
+ /// Verify that `getAuthenticatorKey()` fails with an error when the Authenticator key is not
+ /// present in the keychain
+ ///
+ func test_getAuthenticatorKey_keyNotFound() async throws {
+ let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)
+ keychainService.searchResult = .failure(error)
+
+ await assertAsyncThrows(error: error) {
+ _ = try await subject.getAuthenticatorKey()
+ }
+ }
+
+ /// Verify that `setAuthenticatorKey(_:)` sets a value with the correct search attributes specified.
+ ///
+ func test_setAuthenticatorKey_success() async throws {
+ let key = SymmetricKey(size: .bits256)
+ let data = key.withUnsafeBytes { Data(Array($0)) }
+ try await subject.setAuthenticatorKey(data)
+
+ let attributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any])
+ try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccessGroup] as? String), accessGroup)
+ try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccessible] as? String),
+ String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
+ try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccount] as? String),
+ SharedKeychainItem.authenticatorKey.unformattedKey)
+ try XCTAssertEqual(XCTUnwrap(attributes[kSecClass] as? String),
+ String(kSecClassGenericPassword))
+ try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), data)
+ }
+}
diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift b/AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift
new file mode 100644
index 000000000..db905b4e6
--- /dev/null
+++ b/AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+@testable import AuthenticatorSyncKit
+
+class MockAuthenticatorKeychainService {
+ // MARK: Properties
+
+ var addAttributes: CFDictionary?
+ var addResult: Result = .success(())
+ var deleteQueries = [CFDictionary]()
+ var deleteResult: Result = .success(())
+ var searchQuery: CFDictionary?
+ var searchResult: Result = .success(nil)
+}
+
+// MARK: KeychainService
+
+extension MockAuthenticatorKeychainService: AuthenticatorKeychainService {
+ func add(attributes: CFDictionary) throws {
+ addAttributes = attributes
+ try addResult.get()
+ }
+
+ func delete(query: CFDictionary) throws {
+ deleteQueries.append(query)
+ try deleteResult.get()
+ }
+
+ func search(query: CFDictionary) throws -> AnyObject? {
+ searchQuery = query
+ return try searchResult.get()
+ }
+}
+
+extension MockAuthenticatorKeychainService {
+ func setSearchResultData(_ data: Data) {
+ let dictionary = [kSecValueData as String: data]
+ searchResult = .success(dictionary as AnyObject)
+ }
+}
diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift b/AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift
new file mode 100644
index 000000000..394ec002a
--- /dev/null
+++ b/AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift
@@ -0,0 +1,31 @@
+import CryptoKit
+import Foundation
+
+@testable import AuthenticatorSyncKit
+
+class MockSharedKeychainRepository {
+ var authenticatorKey: Data?
+}
+
+extension MockSharedKeychainRepository: SharedKeychainRepository {
+ func generateKeyData() -> Data {
+ let key = SymmetricKey(size: .bits256)
+ return key.withUnsafeBytes { Data(Array($0)) }
+ }
+
+ func deleteAuthenticatorKey() throws {
+ authenticatorKey = nil
+ }
+
+ func getAuthenticatorKey() async throws -> Data {
+ if let authenticatorKey {
+ return authenticatorKey
+ } else {
+ throw AuthenticatorKeychainServiceError.keyNotFound(.authenticatorKey)
+ }
+ }
+
+ func setAuthenticatorKey(_ value: Data) async throws {
+ authenticatorKey = value
+ }
+}
diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift b/AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift
new file mode 100644
index 000000000..2df928057
--- /dev/null
+++ b/AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift
@@ -0,0 +1,198 @@
+import XCTest
+
+/// Base class for any tests in the AuthenticatorSyncKit framework.
+///
+open class AuthenticatorSyncKitTestCase: XCTestCase {
+ /// Asserts that an asynchronous block of code will throw an error. The test will fail if the
+ /// block does not throw an error.
+ ///
+ /// - Note: This method does not rethrow the error thrown by `block`.
+ ///
+ /// - Parameters:
+ /// - block: The block to be executed. This block is run asynchronously.
+ /// - file: The file in which the failure occurred. Defaults to the file name of the test
+ /// case in which the function was called from.
+ /// - line: The line number in which the failure occurred. Defaults to the line number on
+ /// which this function was called from.
+ ///
+ open func assertAsyncThrows(
+ _ block: () async throws -> Void,
+ file: StaticString = #file,
+ line: UInt = #line
+ ) async {
+ do {
+ try await block()
+ XCTFail("The block did not throw an error.", file: file, line: line)
+ } catch {}
+ }
+
+ /// Asserts that an asynchronous block of code will throw a specific error. The test will fail
+ /// if the block does not throw an error or if the error thrown does not equal the provided error.
+ ///
+ /// - Note: This method does not rethrow the error thrown by `block`.
+ ///
+ /// - Parameters:
+ /// - error: The specific error that must be thrown by `block`.
+ /// - block: The block to be executed. This block is run asynchronously.
+ /// - file: The file in which the failure occurred. Defaults to the file name of the test
+ /// case in which the function was called from.
+ /// - line: The line number in which the failure occurred. Defaults to the line number on
+ /// which this function was called from.
+ ///
+ open func assertAsyncThrows(
+ error: E,
+ file: StaticString = #file,
+ line: UInt = #line,
+ _ block: () async throws -> Void
+ ) async {
+ do {
+ try await block()
+ XCTFail("The block did not throw an error.", file: file, line: line)
+ } catch let caughtError as E {
+ XCTAssertEqual(caughtError, error, file: file, line: line)
+ } catch let caughtError {
+ XCTFail(
+ "The error caught (\(caughtError)) does not match the type of error provided (\(error)).",
+ file: file,
+ line: line
+ )
+ }
+ }
+
+ /// Asserts that an asynchronous block of code does not throw an error. The test will fail
+ /// if the block throws an error.
+ ///
+ /// - Parameters:
+ /// - block: The block to be executed. This block is run asynchronously.
+ /// - file: The file in which the failure occurred. Defaults to the file name of the test
+ /// case in which the function was called from.
+ /// - line: The line number in which the failure occurred. Defaults to the line number on
+ /// which this function was called from.
+ ///
+ open func assertAsyncDoesNotThrow(
+ _ block: () async throws -> Void,
+ file: StaticString = #file,
+ line: UInt = #line
+ ) async {
+ do {
+ try await block()
+ } catch {
+ XCTFail("The block threw an error.", file: file, line: line)
+ }
+ }
+
+ /// Wait for a condition to be true. The test will fail if the condition isn't met before the
+ /// specified timeout.
+ ///
+ /// - Parameters:
+ /// - condition: Return `true` to continue or `false` to keep waiting.
+ /// - timeout: How long to wait before failing.
+ /// - failureMessage: Message to display when the condition fails to be met.
+ /// - file: The file in which the failure occurred. Defaults to the file name of the test
+ /// case in which the function was called from.
+ /// - line: The line number in which the failure occurred. Defaults to the line number on
+ /// which this function was called from.
+ ///
+ open func waitFor(
+ _ condition: () -> Bool,
+ timeout: TimeInterval = 10.0,
+ failureMessage: String = "waitFor condition wasn't met within the time limit",
+ file: StaticString = #file,
+ line: UInt = #line
+ ) {
+ let start = Date()
+ let limit = Date(timeIntervalSinceNow: timeout)
+
+ while !condition(), limit > Date() {
+ let next = Date(timeIntervalSinceNow: 0.2)
+ RunLoop.current.run(mode: RunLoop.Mode.default, before: next)
+ }
+
+ warnIfNeeded(start: start, line: line)
+
+ XCTAssert(condition(), failureMessage, file: file, line: line)
+ }
+
+ /// Wait for a condition to be true. The test will fail if the condition isn't met before the
+ /// specified timeout.
+ ///
+ /// - Parameters:
+ /// - condition: An expression that evaluates to `true` to continue or `false` to keep waiting.
+ /// - timeout: How long to wait before failing.
+ /// - failureMessage: Message to display when the condition fails to be met.
+ /// - file: The file in which the failure occurred. Defaults to the file name of the test
+ /// case in which the function was called from.
+ /// - line: The line number in which the failure occurred. Defaults to the line number on
+ /// which this function was called from.
+ ///
+ open func waitFor(
+ _ condition: @autoclosure () -> Bool,
+ timeout: TimeInterval = 10.0,
+ failureMessage: String = "waitFor condition wasn't met within the time limit",
+ file: StaticString = #file,
+ line: UInt = #line
+ ) {
+ waitFor(
+ condition,
+ timeout: timeout,
+ failureMessage: failureMessage,
+ file: file,
+ line: line
+ )
+ }
+
+ /// Wait for a condition asynchronously to be true. The test will fail if the condition isn't met before the
+ /// specified timeout.
+ ///
+ /// - Parameters:
+ /// - condition: Return `true` to continue or `false` to keep waiting.
+ /// - timeout: How long to wait before failing.
+ /// - failureMessage: Message to display when the condition fails to be met.
+ /// - file: The file in which the failure occurred. Defaults to the file name of the test
+ /// case in which the function was called from.
+ /// - line: The line number in which the failure occurred. Defaults to the line number on
+ /// which this function was called from.
+ ///
+ open func waitForAsync(
+ _ condition: @escaping () -> Bool,
+ timeout: TimeInterval = 10.0,
+ failureMessage: String = "waitForAsync condition wasn't met within the time limit",
+ file: StaticString = #file,
+ line: UInt = #line
+ ) async throws {
+ let start = Date()
+ let limit = Date(timeIntervalSinceNow: timeout)
+
+ while !condition(), limit > Date() {
+ try await Task.sleep(nanoseconds: 2 * 100_000_000)
+ }
+
+ warnIfNeeded(start: start, line: line)
+
+ XCTAssert(condition(), failureMessage, file: file, line: line)
+ }
+
+ /// Warns if `functionName` took more than `afterSeconds` to complete
+ /// - Parameters:
+ /// - start: When `waitFor` started
+ /// - afterSeconds: The seconds that have passed since `start` to check against
+ /// - functionName: The function name
+ /// - line: File line were this was originated
+ private func warnIfNeeded(
+ start: Date,
+ afterSeconds: Int = 3,
+ functionName: String = #function,
+ line: UInt = #line
+ ) {
+ // If the condition took more than 3 seconds to satisfy, add a warning to the logs to look into it.
+ let elapsed = Date().timeIntervalSince(start)
+ if elapsed > 3 {
+ let numberFormatter = NumberFormatter()
+ numberFormatter.maximumFractionDigits = 3
+ numberFormatter.minimumFractionDigits = 3
+ numberFormatter.minimumIntegerDigits = 1
+ let elapsedString: String = numberFormatter.string(from: NSNumber(value: elapsed)) ?? "nil"
+ print("warning: \(name) line \(line) `\(functionName)` took \(elapsedString) seconds")
+ }
+ }
+}
diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist b/AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist
new file mode 100644
index 000000000..7c1df8d12
--- /dev/null
+++ b/AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ BNDL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+
+
diff --git a/BitwardenShared/Core/Auth/Services/KeychainService.swift b/BitwardenShared/Core/Auth/Services/KeychainService.swift
index 196965bfa..3d62dff68 100644
--- a/BitwardenShared/Core/Auth/Services/KeychainService.swift
+++ b/BitwardenShared/Core/Auth/Services/KeychainService.swift
@@ -1,3 +1,4 @@
+import AuthenticatorSyncKit
import Foundation
// MARK: - KeychainService
@@ -113,3 +114,7 @@ class DefaultKeychainService: KeychainService {
}
}
}
+
+// MARK: - AuthenticatorKeychainService
+
+extension DefaultKeychainService: AuthenticatorKeychainService {}
diff --git a/Configs/AuthenticatorSyncKit.xcconfig b/Configs/AuthenticatorSyncKit.xcconfig
new file mode 100644
index 000000000..13f6dd5ec
--- /dev/null
+++ b/Configs/AuthenticatorSyncKit.xcconfig
@@ -0,0 +1,4 @@
+#include "./Common.xcconfig"
+#include? "./Local.xcconfig"
+
+PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).authenticator-sync-kit
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 000000000..864fb5968
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,32 @@
+// swift-tools-version: 5.7
+
+import PackageDescription
+
+let package = Package(
+ name: "BitwardenShared",
+ platforms: [
+ .iOS(.v15),
+ ],
+ products: [
+ .library(
+ name: "AuthenticatorSyncKit",
+ targets: [
+ "AuthenticatorSyncKit",
+ ]
+ ),
+ ],
+ targets: [
+ .target(
+ name: "AuthenticatorSyncKit",
+ path: "AuthenticatorSyncKit",
+ exclude: [
+ "Tests/",
+ ]
+ ),
+ .testTarget(
+ name: "AuthenticatorSyncKitTests",
+ dependencies: ["AuthenticatorSyncKit"],
+ path: "AuthenticatorSyncKit/Tests/"
+ ),
+ ]
+)
diff --git a/project.yml b/project.yml
index bd1b6917b..ca3b49059 100644
--- a/project.yml
+++ b/project.yml
@@ -36,6 +36,15 @@ packages:
url: https://github.com/nalexn/ViewInspector
exactVersion: 0.9.10
schemes:
+ AuthenticatorSyncKit:
+ build:
+ targets:
+ AuthenticatorSyncKit: all
+ AuthenticatorSyncKitTests: [test]
+ test:
+ gatherCoverageData: true
+ targets:
+ - AuthenticatorSyncKitTests
Bitwarden:
build:
targets:
@@ -50,12 +59,14 @@ schemes:
language: en
region: US
coverageTargets:
+ - AuthenticatorSyncKit
- Bitwarden
- BitwardenActionExtension
- BitwardenAutoFillExtension
- BitwardenShareExtension
- BitwardenShared
targets:
+ - AuthenticatorSyncKitTests
- BitwardenTests
- BitwardenActionExtensionTests
- BitwardenAutoFillExtensionTests
@@ -125,6 +136,33 @@ schemes:
targets:
BitwardenWatchWidgetExtension: all
targets:
+ AuthenticatorSyncKit:
+ type: framework
+ platform: iOS
+ configFiles:
+ Debug: Configs/AuthenticatorSyncKit.xcconfig
+ Release: Configs/AuthenticatorSyncKit.xcconfig
+ settings:
+ base:
+ APPLICATION_EXTENSION_API_ONLY: true
+ INFOPLIST_FILE: AuthenticatorSyncKit/Info.plist
+ sources:
+ - path: AuthenticatorSyncKit
+ excludes:
+ - "**/Tests/*"
+ AuthenticatorSyncKitTests:
+ type: bundle.unit-test
+ platform: iOS
+ settings:
+ base:
+ INFOPLIST_FILE: AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist
+ sources:
+ - path: AuthenticatorSyncKit
+ includes:
+ - "**/Tests/*"
+ dependencies:
+ - target: AuthenticatorSyncKit
+ randomExecutionOrder: true
Bitwarden:
type: application
platform: iOS
@@ -154,6 +192,7 @@ targets:
- path: swiftgen.yml
buildPhase: none
dependencies:
+ - target: AuthenticatorSyncKit
- target: BitwardenShared
- target: BitwardenActionExtension
- target: BitwardenAutoFillExtension
@@ -206,6 +245,7 @@ targets:
- "**/TestHelpers/*"
- path: GlobalTestHelpers
dependencies:
+ - target: AuthenticatorSyncKit
- target: Bitwarden
- target: BitwardenShared
- package: SnapshotTesting
@@ -351,6 +391,7 @@ targets:
optional: true
- path: BitwardenWatchShared
dependencies:
+ - target: AuthenticatorSyncKit
- package: BitwardenSdk
- package: Networking
- package: SwiftUIIntrospect