Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ extension BitwardenSdk.GetAssertionResult {
signature: Data = Data(capacity: 64),
userHandle: Data = Data(capacity: 64),
selectedCredential: SelectedCredential = .fixture(),
extensions: GetAssertionExtensionsOutput = .init(prf: nil),
) -> BitwardenSdk.GetAssertionResult {
.init(
credentialId: credentialId,
authenticatorData: authenticatorData,
signature: signature,
userHandle: userHandle,
selectedCredential: selectedCredential,
extensions: extensions,
)
}
}
Expand All @@ -72,11 +74,13 @@ extension BitwardenSdk.MakeCredentialResult {
authenticatorData: Data = Data(capacity: 37),
attestationObject: Data = Data(capacity: 37),
credentialId: Data = Data(capacity: 16),
extensions: MakeCredentialExtensionsOutput = .init(prf: nil),
) -> BitwardenSdk.MakeCredentialResult {
.init(
authenticatorData: authenticatorData,
attestationObject: attestationObject,
credentialId: credentialId,
extensions: extensions,
)
}
}
Expand Down

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion BitwardenShared/Core/Auth/Services/KeychainRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ enum BitwardenKeychainItem: Equatable, KeychainItem {
/// The keychain item for a user's last active time.
case lastActiveTime(userId: String)

/// The keychain item for local user data key states.
case localUserDataKeyStates(userId: String)
/// The keychain item for a user's last active monotonic time.
case lastActiveMonotonicTime(userId: String)

Expand Down Expand Up @@ -70,6 +72,7 @@ enum BitwardenKeychainItem: Equatable, KeychainItem {
.lastActiveBootEpoch,
.lastActiveMonotonicTime,
.lastActiveTime,
.localUserDataKeyStates,
.neverLock,
.pendingAdminLoginRequest,
.refreshToken,
Expand Down Expand Up @@ -101,6 +104,7 @@ enum BitwardenKeychainItem: Equatable, KeychainItem {
case .accessToken,
.authenticatorVaultKey,
.clientCertificateIdentity,
.localUserDataKeyStates,
.refreshToken,
.serverCommunicationConfig:
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
Expand Down Expand Up @@ -131,6 +135,8 @@ enum BitwardenKeychainItem: Equatable, KeychainItem {
"lastActiveMonotonicTime_\(userId)"
case let .lastActiveTime(userId):
"lastActiveTime_\(userId)"
case let .localUserDataKeyStates(userId):
"localUserDataKeyStates_\(userId)"
case let .neverLock(userId: id):
"userKeyAutoUnlock_" + id
case let .pendingAdminLoginRequest(userId):
Expand All @@ -149,7 +155,8 @@ enum BitwardenKeychainItem: Equatable, KeychainItem {

// MARK: - KeychainRepository

protocol KeychainRepository: AnyObject, ServerCommunicationConfigKeychainRepository { // sourcery: AutoMockable
// swiftlint:disable:next line_length
protocol KeychainRepository: AnyObject, ServerCommunicationConfigKeychainRepository, LocalUserDataKeychainRepository { // sourcery: AutoMockable
/// Deletes all items stored in the keychain.
///
func deleteAllItems() async throws
Expand Down Expand Up @@ -308,6 +315,10 @@ class DefaultKeychainRepository: KeychainRepository {
///
let keychainServiceFacade: KeychainServiceFacade

/// Serializes concurrent mutations to local user data key states per user ID.
///
let localUserDataKeyStateMutationSerializer = SerialWorker()

// MARK: Initialization

init(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import BitwardenKit
import Foundation

/// A service that provides access to keychain values related to LocalUserData.
///
protocol LocalUserDataKeychainRepository { // sourcery: AutoMockable
/// Gets the local user data key states for a user from the keychain.
///
/// - Parameter userId: The user ID associated with the local `UserKeyData` states.
/// - Returns: A dictionary mapping key IDs to `UserKeyData`, or `nil` if not stored.
///
func getLocalUserDataKeyStates(userId: String) async throws -> [String: UserKeyData]?

/// Atomically clears all local user data key states for a user.
/// Serialized against any in-flight mutations for `userId`, so a concurrent
/// `mutateLocalUserDataKeyStates` cannot resurrect cleared state.
///
/// - Parameter userId: The user ID associated with the local `UserKeyData` states.
///
func clearLocalUserDataKeyStates(userId: String) async throws

/// Atomically reads, transforms, and writes the local user data key states for a user.
/// Concurrent calls for the same `userId` are serialized; calls for different user IDs are independent.
///
/// - Parameters:
/// - userId: The user ID associated with the local `UserKeyData` states.
/// - transform: A closure that mutates the current key states in place.
///
func mutateLocalUserDataKeyStates(
userId: String,
_ transform: @escaping @Sendable (inout [String: UserKeyData]) -> Void,
) async throws
}

extension DefaultKeychainRepository: LocalUserDataKeychainRepository {
func getLocalUserDataKeyStates(userId: String) async throws -> [String: UserKeyData]? {
do {
return try await keychainServiceFacade.getValue(
for: BitwardenKeychainItem.localUserDataKeyStates(userId: userId),
)
} catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound {
return nil
}
}

func clearLocalUserDataKeyStates(userId: String) async throws {
try await localUserDataKeyStateMutationSerializer.enqueue(userId: userId) { [weak self] in
guard let self else { return }
try await setLocalUserDataKeyStates(nil, userId: userId)
}
}

func mutateLocalUserDataKeyStates(
userId: String,
_ transform: @escaping @Sendable (inout [String: UserKeyData]) -> Void,
) async throws {
try await localUserDataKeyStateMutationSerializer.enqueue(userId: userId) { [weak self] in
guard let self else { return }
var states = try await getLocalUserDataKeyStates(userId: userId) ?? [:]
transform(&states)
try await setLocalUserDataKeyStates(states.nilIfEmpty, userId: userId)
}
}

private func setLocalUserDataKeyStates(_ states: [String: UserKeyData]?, userId: String) async throws {
if let states {
try await keychainServiceFacade.setValue(
states,
for: BitwardenKeychainItem.localUserDataKeyStates(userId: userId),
)
} else {
try await keychainServiceFacade.deleteValue(
for: BitwardenKeychainItem.localUserDataKeyStates(userId: userId),
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import BitwardenKit
import BitwardenKitMocks
import Foundation
import Testing

@testable import BitwardenShared

struct LocalUserDataKeychainRepositoryTests {
// MARK: Properties

let keychainServiceFacade: MockKeychainServiceFacade
let subject: DefaultKeychainRepository

// MARK: Setup

init() {
keychainServiceFacade = MockKeychainServiceFacade()
subject = DefaultKeychainRepository(
keychainService: MockKeychainService(),
keychainServiceFacade: keychainServiceFacade,
)
}

// MARK: Tests - clearLocalUserDataKeyStates

/// `clearLocalUserDataKeyStates(userId:)` deletes the stored states via the facade.
///
@Test
func clearLocalUserDataKeyStates_deletesItem() async throws {
try await subject.clearLocalUserDataKeyStates(userId: "1")

let receivedUnformattedKey = keychainServiceFacade.deleteValueReceivedItem?.unformattedKey
let expectedUnformattedKey = BitwardenKeychainItem.localUserDataKeyStates(userId: "1").unformattedKey
#expect(receivedUnformattedKey == expectedUnformattedKey)
}

/// `clearLocalUserDataKeyStates(userId:)` rethrows errors from the facade.
///
@Test
func clearLocalUserDataKeyStates_error_rethrows() async {
let error = KeychainServiceError.osStatusError(-1)
keychainServiceFacade.deleteValueThrowableError = error

await #expect(throws: error) {
try await subject.clearLocalUserDataKeyStates(userId: "1")
}
}

// MARK: Tests - getLocalUserDataKeyStates

/// `getLocalUserDataKeyStates(userId:)` returns decoded states from the facade.
///
@Test
func getLocalUserDataKeyStates_success() async throws {
let expected: [String: UserKeyData] = ["key1": UserKeyData(wrappedKey: "encKey1")]
let jsonData = try JSONEncoder.defaultEncoder.encode(expected)
keychainServiceFacade.getValueReturnValue = String(data: jsonData, encoding: .utf8)

let result = try await subject.getLocalUserDataKeyStates(userId: "1")
let receivedUnformattedKey = keychainServiceFacade.getValueReceivedItem?.unformattedKey
let expectedUnformattedKey = BitwardenKeychainItem.localUserDataKeyStates(userId: "1").unformattedKey

#expect(result == expected)
#expect(receivedUnformattedKey == expectedUnformattedKey)
}

/// `getLocalUserDataKeyStates(userId:)` returns nil when the OS status indicates not found.
///
@Test
func getLocalUserDataKeyStates_osStatusNotFound_returnsNil() async throws {
keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound)

let result = try await subject.getLocalUserDataKeyStates(userId: "1")

#expect(result == nil)
}

/// `getLocalUserDataKeyStates(userId:)` returns nil when the key is not found.
///
@Test
func getLocalUserDataKeyStates_keyNotFound_returnsNil() async throws {
keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound(
BitwardenKeychainItem.localUserDataKeyStates(userId: "1"),
)

let result = try await subject.getLocalUserDataKeyStates(userId: "1")

#expect(result == nil)
}

/// `getLocalUserDataKeyStates(userId:)` rethrows unexpected errors.
///
@Test
func getLocalUserDataKeyStates_otherError_rethrows() async {
let error = KeychainServiceError.osStatusError(-1)
keychainServiceFacade.getValueThrowableError = error

await #expect(throws: error) {
_ = try await subject.getLocalUserDataKeyStates(userId: "1")
}
}

// MARK: Tests - mutateLocalUserDataKeyStates

/// `mutateLocalUserDataKeyStates(userId:_:)` applies the transform to existing states and stores the result.
///
@Test
func mutateLocalUserDataKeyStates_transformsExistingStates() async throws {
let initial: [String: UserKeyData] = ["key1": UserKeyData(wrappedKey: "encKey1")]
let jsonData = try JSONEncoder.defaultEncoder.encode(initial)
keychainServiceFacade.getValueReturnValue = String(data: jsonData, encoding: .utf8)

try await subject.mutateLocalUserDataKeyStates(userId: "1") { states in
states["key2"] = UserKeyData(wrappedKey: "encKey2")
}

let storedJSON = try #require(keychainServiceFacade.setValueReceivedArguments?.value)
let stored = try JSONDecoder.defaultDecoder.decode(
[String: UserKeyData].self,
from: #require(storedJSON.data(using: .utf8)),
)
#expect(stored["key1"] == UserKeyData(wrappedKey: "encKey1"))
#expect(stored["key2"] == UserKeyData(wrappedKey: "encKey2"))
}

/// `mutateLocalUserDataKeyStates(userId:_:)` starts with an empty dict when no states are stored.
///
@Test
func mutateLocalUserDataKeyStates_noExistingStates_startsEmpty() async throws {
keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound(
BitwardenKeychainItem.localUserDataKeyStates(userId: "1"),
)

try await subject.mutateLocalUserDataKeyStates(userId: "1") { states in
states["key1"] = UserKeyData(wrappedKey: "encKey1")
}

let storedJSON = try #require(keychainServiceFacade.setValueReceivedArguments?.value)
let stored = try JSONDecoder.defaultDecoder.decode(
[String: UserKeyData].self,
from: #require(storedJSON.data(using: .utf8)),
)
#expect(stored == ["key1": UserKeyData(wrappedKey: "encKey1")])
}

/// `mutateLocalUserDataKeyStates(userId:_:)` deletes the keychain item when the transform produces an empty dict.
///
@Test
func mutateLocalUserDataKeyStates_emptyResultAfterTransform_deletesItem() async throws {
let initial: [String: UserKeyData] = ["key1": UserKeyData(wrappedKey: "encKey1")]
let jsonData = try JSONEncoder.defaultEncoder.encode(initial)
keychainServiceFacade.getValueReturnValue = String(data: jsonData, encoding: .utf8)

try await subject.mutateLocalUserDataKeyStates(userId: "1") { states in
states.removeAll()
}

let receivedUnformattedKey = keychainServiceFacade.deleteValueReceivedItem?.unformattedKey
let expectedUnformattedKey = BitwardenKeychainItem.localUserDataKeyStates(userId: "1").unformattedKey
#expect(receivedUnformattedKey == expectedUnformattedKey)
#expect(!keychainServiceFacade.setValueCalled)
}

/// `mutateLocalUserDataKeyStates(userId:_:)` rethrows errors from getting the current states.
///
@Test
func mutateLocalUserDataKeyStates_getError_rethrows() async {
let error = KeychainServiceError.osStatusError(-1)
keychainServiceFacade.getValueThrowableError = error

await #expect(throws: error) {
try await subject.mutateLocalUserDataKeyStates(userId: "1") { _ in }
}
}

/// `mutateLocalUserDataKeyStates(userId:_:)` rethrows errors from storing the updated states.
///
@Test
func mutateLocalUserDataKeyStates_setError_rethrows() async {
keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound(
BitwardenKeychainItem.localUserDataKeyStates(userId: "1"),
)
let error = KeychainServiceError.osStatusError(-1)
keychainServiceFacade.setValueThrowableError = error

await #expect(throws: error) {
try await subject.mutateLocalUserDataKeyStates(userId: "1") { states in
states["key1"] = UserKeyData(wrappedKey: "encKey1")
}
}
}
}
Loading
Loading