Skip to content

Commit

Permalink
[BITAU-137] Create AuthenticatorSyncKit SDK (#913)
Browse files Browse the repository at this point in the history
  • Loading branch information
brant-livefront authored Sep 12, 2024
1 parent 121a26d commit 6af83fc
Show file tree
Hide file tree
Showing 12 changed files with 710 additions and 0 deletions.
38 changes: 38 additions & 0 deletions AuthenticatorSyncKit/AuthenticatorKeychainService.swift
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)
}
18 changes: 18 additions & 0 deletions AuthenticatorSyncKit/Info.plist
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>
155 changes: 155 additions & 0 deletions AuthenticatorSyncKit/SharedKeychainRepository.swift
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 AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift
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)
}
}
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)
}
}
Loading

0 comments on commit 6af83fc

Please sign in to comment.