Skip to content

Commit 6af83fc

Browse files
[BITAU-137] Create AuthenticatorSyncKit SDK (#913)
1 parent 121a26d commit 6af83fc

12 files changed

+710
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
3+
// MARK: - AuthenticatorKeychainService
4+
5+
/// A Service to provide a wrapper around the device keychain shared via App Group between
6+
/// the Authenticator and the main Bitwarden app.
7+
///
8+
public protocol AuthenticatorKeychainService: AnyObject {
9+
/// Adds a set of attributes.
10+
///
11+
/// - Parameter attributes: Attributes to add.
12+
///
13+
func add(attributes: CFDictionary) throws
14+
15+
/// Attempts a deletion based on a query.
16+
///
17+
/// - Parameter query: Query for the delete.
18+
///
19+
func delete(query: CFDictionary) throws
20+
21+
/// Searches for a query.
22+
///
23+
/// - Parameter query: Query for the search.
24+
/// - Returns: The search results.
25+
///
26+
func search(query: CFDictionary) throws -> AnyObject?
27+
}
28+
29+
// MARK: - AuthenticatorKeychainServiceError
30+
31+
/// Enum with possible error cases that can be thrown from `AuthenticatorKeychainService`.
32+
public enum AuthenticatorKeychainServiceError: Error, Equatable {
33+
/// When a `KeychainService` is unable to locate an auth key for a given storage key.
34+
///
35+
/// - Parameter KeychainItem: The potential storage key for the auth key.
36+
///
37+
case keyNotFound(SharedKeychainItem)
38+
}

AuthenticatorSyncKit/Info.plist

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleExecutable</key>
6+
<string>$(EXECUTABLE_NAME)</string>
7+
<key>CFBundleIdentifier</key>
8+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
9+
<key>CFBundleName</key>
10+
<string>AuthenticatorSyncKit</string>
11+
<key>CFBundlePackageType</key>
12+
<string>FMWK</string>
13+
<key>CFBundleShortVersionString</key>
14+
<string>1.0</string>
15+
<key>CFBundleVersion</key>
16+
<string>1</string>
17+
</dict>
18+
</plist>
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import Foundation
2+
3+
// MARK: - SharedKeychainItem
4+
5+
/// Enumeration of support Keychain Items that can be placed in the `SharedKeychainRepository`
6+
///
7+
public enum SharedKeychainItem: Equatable {
8+
/// The keychain item for the authenticator encryption key.
9+
case authenticatorKey
10+
11+
/// The storage key for this keychain item.
12+
///
13+
var unformattedKey: String {
14+
switch self {
15+
case .authenticatorKey:
16+
"authenticatorKey"
17+
}
18+
}
19+
}
20+
21+
// MARK: - SharedKeychainRepository
22+
23+
/// A repository for managing keychain items to be shared between the main Bitwarden app and the Authenticator app.
24+
///
25+
public protocol SharedKeychainRepository: AnyObject {
26+
/// Attempts to delete the authenticator key from the keychain.
27+
///
28+
func deleteAuthenticatorKey() throws
29+
30+
/// Gets the authenticator key.
31+
///
32+
/// - Returns: Data representing the authenticator key.
33+
///
34+
func getAuthenticatorKey() async throws -> Data
35+
36+
/// Stores the access token for a user in the keychain.
37+
///
38+
/// - Parameter value: The authenticator key to store.
39+
///
40+
func setAuthenticatorKey(_ value: Data) async throws
41+
}
42+
43+
// MARK: - DefaultKeychainRepository
44+
45+
/// A concreate implementation of the `SharedKeychainRepository` protocol.
46+
///
47+
public class DefaultSharedKeychainRepository: SharedKeychainRepository {
48+
// MARK: Properties
49+
50+
/// An identifier for the shared access group used by the application.
51+
///
52+
/// Example: "group.com.8bit.bitwarden"
53+
///
54+
private let sharedAppGroupIdentifier: String
55+
56+
/// The keychain service used by the repository
57+
///
58+
private let keychainService: AuthenticatorKeychainService
59+
60+
// MARK: Initialization
61+
62+
/// Initialize a `DefaultSharedKeychainRepository`.
63+
///
64+
/// - Parameters:
65+
/// - sharedAppGroupIdentifier: An identifier for the shared access group used by the application.
66+
/// - keychainService: The keychain service used by the repository
67+
public init(
68+
sharedAppGroupIdentifier: String,
69+
keychainService: AuthenticatorKeychainService
70+
) {
71+
self.sharedAppGroupIdentifier = sharedAppGroupIdentifier
72+
self.keychainService = keychainService
73+
}
74+
75+
// MARK: Methods
76+
77+
/// Retrieve the value for the specific item from the Keychain Service.
78+
///
79+
/// - Parameter item: the keychain item for which to retrieve a value.
80+
/// - Returns: The value (Data) stored in the keychain for the given item.
81+
///
82+
private func getSharedValue(for item: SharedKeychainItem) async throws -> Data {
83+
let foundItem = try keychainService.search(
84+
query: [
85+
kSecMatchLimit: kSecMatchLimitOne,
86+
kSecReturnData: true,
87+
kSecReturnAttributes: true,
88+
kSecAttrAccessGroup: sharedAppGroupIdentifier,
89+
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
90+
kSecAttrAccount: item.unformattedKey,
91+
kSecClass: kSecClassGenericPassword,
92+
] as CFDictionary
93+
)
94+
95+
guard let resultDictionary = foundItem as? [String: Any],
96+
let data = resultDictionary[kSecValueData as String] as? Data else {
97+
throw AuthenticatorKeychainServiceError.keyNotFound(item)
98+
}
99+
100+
return data
101+
}
102+
103+
/// Store a given value into the keychain for the given item.
104+
///
105+
/// - Parameters:
106+
/// - value: The value (Data) to be stored into the keychain
107+
/// - item: The item for which to store the value in the keychain.
108+
///
109+
private func setSharedValue(_ value: Data, for item: SharedKeychainItem) async throws {
110+
let query = [
111+
kSecValueData: value,
112+
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
113+
kSecAttrAccessGroup: sharedAppGroupIdentifier,
114+
kSecAttrAccount: item.unformattedKey,
115+
kSecClass: kSecClassGenericPassword,
116+
] as CFDictionary
117+
118+
try? keychainService.delete(query: query)
119+
120+
try keychainService.add(
121+
attributes: query
122+
)
123+
}
124+
}
125+
126+
public extension DefaultSharedKeychainRepository {
127+
/// Attempts to delete the authenticator key from the keychain.
128+
///
129+
func deleteAuthenticatorKey() throws {
130+
try keychainService.delete(
131+
query: [
132+
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
133+
kSecAttrAccessGroup: sharedAppGroupIdentifier,
134+
kSecAttrAccount: SharedKeychainItem.authenticatorKey.unformattedKey,
135+
kSecClass: kSecClassGenericPassword,
136+
] as CFDictionary
137+
)
138+
}
139+
140+
/// Gets the authenticator key.
141+
///
142+
/// - Returns: Data representing the authenticator key.
143+
///
144+
func getAuthenticatorKey() async throws -> Data {
145+
try await getSharedValue(for: .authenticatorKey)
146+
}
147+
148+
/// Stores the access token for a user in the keychain.
149+
///
150+
/// - Parameter value: The authenticator key to store.
151+
///
152+
func setAuthenticatorKey(_ value: Data) async throws {
153+
try await setSharedValue(value, for: .authenticatorKey)
154+
}
155+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import CryptoKit
2+
import Foundation
3+
import XCTest
4+
5+
@testable import AuthenticatorSyncKit
6+
7+
final class SharedKeychainRepositoryTests: AuthenticatorSyncKitTestCase {
8+
// MARK: Properties
9+
10+
let accessGroup = "group.com.example.bitwarden"
11+
var keychainService: MockAuthenticatorKeychainService!
12+
var subject: DefaultSharedKeychainRepository!
13+
14+
// MARK: Setup & Teardown
15+
16+
override func setUp() {
17+
keychainService = MockAuthenticatorKeychainService()
18+
subject = DefaultSharedKeychainRepository(
19+
sharedAppGroupIdentifier: accessGroup,
20+
keychainService: keychainService
21+
)
22+
}
23+
24+
override func tearDown() {
25+
keychainService = nil
26+
subject = nil
27+
}
28+
29+
// MARK: Tests
30+
31+
/// Verify that `deleteAuthenticatorKey()` issues a delete with the correct search attributes specified.
32+
///
33+
func test_deleteAuthenticatorKey_success() async throws {
34+
try subject.deleteAuthenticatorKey()
35+
36+
let queries = try XCTUnwrap(keychainService.deleteQueries as? [[CFString: Any]])
37+
XCTAssertEqual(queries.count, 1)
38+
39+
let query = try XCTUnwrap(queries.first)
40+
try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessGroup] as? String), accessGroup)
41+
try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessible] as? String),
42+
String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
43+
try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccount] as? String),
44+
SharedKeychainItem.authenticatorKey.unformattedKey)
45+
try XCTAssertEqual(XCTUnwrap(query[kSecClass] as? String),
46+
String(kSecClassGenericPassword))
47+
}
48+
49+
/// Verify that `getAuthenticatorKey()` returns a value successfully when one is set. Additionally, verify the
50+
/// search attributes are specified correctly.
51+
///
52+
func test_getAuthenticatorKey_success() async throws {
53+
let key = SymmetricKey(size: .bits256)
54+
let data = key.withUnsafeBytes { Data(Array($0)) }
55+
56+
keychainService.setSearchResultData(data)
57+
58+
let returnData = try await subject.getAuthenticatorKey()
59+
XCTAssertEqual(returnData, data)
60+
61+
let query = try XCTUnwrap(keychainService.searchQuery as? [CFString: Any])
62+
try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessGroup] as? String), accessGroup)
63+
try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessible] as? String),
64+
String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
65+
try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccount] as? String),
66+
SharedKeychainItem.authenticatorKey.unformattedKey)
67+
try XCTAssertEqual(XCTUnwrap(query[kSecClass] as? String), String(kSecClassGenericPassword))
68+
try XCTAssertEqual(XCTUnwrap(query[kSecMatchLimit] as? String), String(kSecMatchLimitOne))
69+
try XCTAssertTrue(XCTUnwrap(query[kSecReturnAttributes] as? Bool))
70+
try XCTAssertTrue(XCTUnwrap(query[kSecReturnData] as? Bool))
71+
}
72+
73+
/// Verify that `getAuthenticatorKey()` fails with a `keyNotFound` error when an unexpected
74+
/// result is returned instead of the key data from the keychain
75+
///
76+
func test_getAuthenticatorKey_badResult() async throws {
77+
let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)
78+
keychainService.searchResult = .success([kSecValueData as String: NSObject()] as AnyObject)
79+
80+
await assertAsyncThrows(error: error) {
81+
_ = try await subject.getAuthenticatorKey()
82+
}
83+
}
84+
85+
/// Verify that `getAuthenticatorKey()` fails with a `keyNotFound` error when a nil
86+
/// result is returned instead of the key data from the keychain
87+
///
88+
func test_getAuthenticatorKey_nilResult() async throws {
89+
let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)
90+
keychainService.searchResult = .success(nil)
91+
92+
await assertAsyncThrows(error: error) {
93+
_ = try await subject.getAuthenticatorKey()
94+
}
95+
}
96+
97+
/// Verify that `getAuthenticatorKey()` fails with an error when the Authenticator key is not
98+
/// present in the keychain
99+
///
100+
func test_getAuthenticatorKey_keyNotFound() async throws {
101+
let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)
102+
keychainService.searchResult = .failure(error)
103+
104+
await assertAsyncThrows(error: error) {
105+
_ = try await subject.getAuthenticatorKey()
106+
}
107+
}
108+
109+
/// Verify that `setAuthenticatorKey(_:)` sets a value with the correct search attributes specified.
110+
///
111+
func test_setAuthenticatorKey_success() async throws {
112+
let key = SymmetricKey(size: .bits256)
113+
let data = key.withUnsafeBytes { Data(Array($0)) }
114+
try await subject.setAuthenticatorKey(data)
115+
116+
let attributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any])
117+
try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccessGroup] as? String), accessGroup)
118+
try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccessible] as? String),
119+
String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly))
120+
try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccount] as? String),
121+
SharedKeychainItem.authenticatorKey.unformattedKey)
122+
try XCTAssertEqual(XCTUnwrap(attributes[kSecClass] as? String),
123+
String(kSecClassGenericPassword))
124+
try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), data)
125+
}
126+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
3+
@testable import AuthenticatorSyncKit
4+
5+
class MockAuthenticatorKeychainService {
6+
// MARK: Properties
7+
8+
var addAttributes: CFDictionary?
9+
var addResult: Result<Void, AuthenticatorKeychainServiceError> = .success(())
10+
var deleteQueries = [CFDictionary]()
11+
var deleteResult: Result<Void, AuthenticatorKeychainServiceError> = .success(())
12+
var searchQuery: CFDictionary?
13+
var searchResult: Result<AnyObject?, AuthenticatorKeychainServiceError> = .success(nil)
14+
}
15+
16+
// MARK: KeychainService
17+
18+
extension MockAuthenticatorKeychainService: AuthenticatorKeychainService {
19+
func add(attributes: CFDictionary) throws {
20+
addAttributes = attributes
21+
try addResult.get()
22+
}
23+
24+
func delete(query: CFDictionary) throws {
25+
deleteQueries.append(query)
26+
try deleteResult.get()
27+
}
28+
29+
func search(query: CFDictionary) throws -> AnyObject? {
30+
searchQuery = query
31+
return try searchResult.get()
32+
}
33+
}
34+
35+
extension MockAuthenticatorKeychainService {
36+
func setSearchResultData(_ data: Data) {
37+
let dictionary = [kSecValueData as String: data]
38+
searchResult = .success(dictionary as AnyObject)
39+
}
40+
}

0 commit comments

Comments
 (0)