generated from bitwarden/template
-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[BITAU-137] Create AuthenticatorSyncKit SDK (#913)
- Loading branch information
1 parent
121a26d
commit 6af83fc
Showing
12 changed files
with
710 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
<plist version="1.0"> | ||
<dict> | ||
<key>CFBundleExecutable</key> | ||
<string>$(EXECUTABLE_NAME)</string> | ||
<key>CFBundleIdentifier</key> | ||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||
<key>CFBundleName</key> | ||
<string>AuthenticatorSyncKit</string> | ||
<key>CFBundlePackageType</key> | ||
<string>FMWK</string> | ||
<key>CFBundleShortVersionString</key> | ||
<string>1.0</string> | ||
<key>CFBundleVersion</key> | ||
<string>1</string> | ||
</dict> | ||
</plist> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
126 changes: 126 additions & 0 deletions
126
AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
40 changes: 40 additions & 0 deletions
40
AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import Foundation | ||
|
||
@testable import AuthenticatorSyncKit | ||
|
||
class MockAuthenticatorKeychainService { | ||
// MARK: Properties | ||
|
||
var addAttributes: CFDictionary? | ||
var addResult: Result<Void, AuthenticatorKeychainServiceError> = .success(()) | ||
var deleteQueries = [CFDictionary]() | ||
var deleteResult: Result<Void, AuthenticatorKeychainServiceError> = .success(()) | ||
var searchQuery: CFDictionary? | ||
var searchResult: Result<AnyObject?, AuthenticatorKeychainServiceError> = .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) | ||
} | ||
} |
Oops, something went wrong.